From 595c79303c0d1d62eb69679d5b8e655ed202cb58 Mon Sep 17 00:00:00 2001 From: sergeyf Date: Sun, 12 Jul 2026 10:09:33 -0700 Subject: [PATCH 1/2] Release canonical name normalization 0.4.0 --- README.md | 72 + docs/canonical_name_evaluation.md | 115 ++ pyproject.toml | 3 +- scripts/build_east_asian_name_lexicons.py | 267 ++++ sinonym/chinese_names_data.py | 14 +- sinonym/coretypes/__init__.py | 4 + sinonym/coretypes/results.py | 80 +- sinonym/data/EAST_ASIAN_NAME_LEXICONS.md | 34 + sinonym/data/README.md | 13 + .../data/east_asian_roman_lexicons.json.gz | Bin 0 -> 30052 bytes sinonym/data/japanese_native_lexicons.json.gz | Bin 0 -> 210081 bytes sinonym/detector.py | 300 +++- sinonym/pipeline/name_order_routing.py | 19 +- sinonym/services/__init__.py | 12 + sinonym/services/cache.py | 12 + sinonym/services/east_asian_name_order.py | 354 +++++ sinonym/services/ethnicity.py | 146 +- sinonym/services/formatting.py | 99 +- sinonym/services/name_lookup.py | 22 + sinonym/services/normalization.py | 20 +- sinonym/services/parsing.py | 2 +- sinonym/services/person_name_normalization.py | 1415 +++++++++++++++++ sinonym/timo/config.yaml | 20 + sinonym/timo/integration_test.py | 41 + sinonym/timo/interface.py | 439 ++++- tests/test_bug_report_fixes.py | 37 +- tests/test_canonical_name_integration.py | 118 ++ tests/test_canonical_results.py | 86 + ...t_chinese_classification_evidence_gates.py | 238 +++ tests/test_east_asian_name_order.py | 123 ++ tests/test_person_name_normalization.py | 1278 +++++++++++++++ tests/test_regression_proposals.py | 18 + tests/test_timo_v2_interface.py | 194 +++ uv.lock | 2 +- 34 files changed, 5531 insertions(+), 66 deletions(-) create mode 100644 docs/canonical_name_evaluation.md create mode 100644 scripts/build_east_asian_name_lexicons.py create mode 100644 sinonym/data/EAST_ASIAN_NAME_LEXICONS.md create mode 100644 sinonym/data/east_asian_roman_lexicons.json.gz create mode 100644 sinonym/data/japanese_native_lexicons.json.gz create mode 100644 sinonym/services/east_asian_name_order.py create mode 100644 sinonym/services/person_name_normalization.py create mode 100644 tests/test_canonical_name_integration.py create mode 100644 tests/test_canonical_results.py create mode 100644 tests/test_chinese_classification_evidence_gates.py create mode 100644 tests/test_east_asian_name_order.py create mode 100644 tests/test_person_name_normalization.py create mode 100644 tests/test_timo_v2_interface.py diff --git a/README.md b/README.md index 5f0356c..9ed59d3 100644 --- a/README.md +++ b/README.md @@ -195,7 +195,9 @@ if result.success: result = detector.normalize_name("John Smith") if not result.success: print(f"Error: {result.error_message}") + print(f"Canonical person name: {result.canonical_name.text}") # Expected Output: Error: name not recognised as Chinese + # Canonical person name: John Smith # --- Example 5: Japanese name in Chinese characters (ML-enhanced detection) --- result = detector.normalize_name("山田太郎") @@ -251,6 +253,76 @@ When you call `normalize_name`, you get a `ParseResult` with helpful structured - `parsed_original_order`: A `ParsedName` with the same semantic `surname` and `given_name` labels as `parsed`, plus an `order` list that records how those components appeared in the input. +- `canonical_name`: an all-person canonical representation. This is populated + for Chinese and non-Chinese people, while the legacy `success`, `result`, and + `parsed` fields remain Chinese-recognition fields. + +### Canonical names for all people + +`canonical_name.text` is the fully normalized display form. Its `normalized` +components expose `given_name`, `middle_name`, `surname`, and `suffix`, plus +immutable token tuples and their display order. Name dashes and apostrophes are +standardized to ASCII `-` and `'`; obvious titles and credentials are removed; +and true generational suffixes are kept in the suffix field. + +Periods are treated by role and shape rather than removed globally. Known +leading titles and trailing credentials are consumed; generational suffixes are +canonicalized; pure dotted initial clusters are uppercased while preserving the +source dot positions; transliteration abbreviations such as ``M.Yu.`` retain +their mixed casing; and a terminal full stop on an ordinary word is removed as +sentence punctuation. + +```python +western = detector.normalize_name("Dr. Ana–Maria O’Neill PhD") +assert not western.success # unchanged: not recognized as Chinese +assert western.parsed is None +assert western.canonical_name.text == "Ana-Maria O'Neill" +assert western.canonical_name.normalized.given_name == "Ana-Maria" +assert western.canonical_name.normalized.surname == "O'Neill" + +suffixed = detector.normalize_person_name("Steve Blando IV") +assert suffixed.text == "Steve Blando IV" +assert suffixed.normalized.suffix == "IV" + +repaired = detector.normalize_person_name_components( + first_name="dr steve", + middle_name="marsh", + last_name="phd", +) +assert repaired.text == "Steve Marsh" +assert repaired.normalized.given_name == "Steve" +assert repaired.normalized.middle_name == "" +assert repaired.normalized.surname == "Marsh" +``` + +For an undelimited raw name, token order alone cannot always distinguish middle +names from multi-token family names. Passing structured components makes the +caller's first/middle/last assignment the default normalized contract. Cultural +convention alone does not move a source middle token into the given or family +field. The same conservative East Asian router used for raw names may reinterpret +a structured source sequence when its directional evidence is decisive; the +structured ``canonical_name.source`` still retains the caller's original roles, +order, and token lineage. Otherwise, roles are re-inferred only when mechanical +cleanup, such as removing a title or credential, empties a required boundary. + +Raw parsing preserves visible order by default. A separate conservative router +assigns semantic family-first components only for evidence combinations that +held the existing non-Chinese benchmark constant: three-syllable compact +Hangul; Japanese native text supported by the Chinese/Japanese classifier and +component dictionaries; strict Korean romanized shapes; diacritic-bearing +Vietnamese with a supported initial surname; and two-token Japanese +romanizations whose surname/given dictionaries support only the family-first +direction. Ambiguous or unsupported names retain the generic input-order +normalization. + +The East Asian component assets contain no complete-person exceptions. Their +sources, hashes, licenses, and regeneration command are documented in +`sinonym/data/EAST_ASIAN_NAME_LEXICONS.md` and +`scripts/build_east_asian_name_lexicons.py`. + +TIMO clients can opt into `sinonym_v2` or `sinonym_routing_v2` to receive the +same nested canonical payload. The existing `sinonym_v1` and +`sinonym_routing_v1` response schemas remain unchanged. Notes: - The tokens in `parsed` and `parsed_original_order` are the same normalized tokens; only the conceptual ordering differs via the `order` list. diff --git a/docs/canonical_name_evaluation.md b/docs/canonical_name_evaluation.md new file mode 100644 index 0000000..1ac99bd --- /dev/null +++ b/docs/canonical_name_evaluation.md @@ -0,0 +1,115 @@ +# Canonical name normalization evaluation + +## Result + +The final rules were evaluated on 1,000 manually reviewed, real non-Chinese +author names: + +| Measure | Exact | Accuracy | +|---|---:|---:| +| Person outcome | 1,000 / 1,000 | 100% | +| Canonical display text | 1,000 / 1,000 | 100% | +| First/given component | 1,000 / 1,000 | 100% | +| Suffix component | 1,000 / 1,000 | 100% | +| Middle component | 982 / 1,000 | 98.2% | +| Last/surname component | 982 / 1,000 | 98.2% | +| All five fields together | 982 / 1,000 | 98.2% | + +The 18 component-only differences are reported as semantic-boundary +differences, separately from display accuracy. Raw undelimited strings cannot +identify those middle/surname boundaries reliably. When callers provide +structured first/middle/last fields, those source roles are authoritative by +default and are repaired only after mechanical cleanup empties a boundary. + +The originally frozen 200-name holdout scores 196/200 (98.0%) on semantic +components and 200/200 on canonical display. The four component differences +remain visible under the source-preserving policy rather than being closed by +authority-specific family-span rules. + +## Data and manual review + +The sampling pool combined: + +- 978 selected names from the internal S2 scholarly-author Parquet. The pinned + source file contains 41,551,414 rows and has SHA-256 + `E4050B4832DAAF2F0B8464F0B78E6CA0D35412897BDDA5AF148F7BACBEE9D363`. +- 22 selected names from the repository's ACL 2025 author list. + +Candidate sampling was deterministic, source-balanced, deduplicated, and +stratified by visible name shape. The final 1,000 contain: + +| Shape | Count | +|---|---:| +| Ordinary | 498 | +| Initials | 130 | +| Non-ASCII Latin | 94 | +| Hyphen | 69 | +| Apostrophe | 57 | +| Family particle | 42 | +| Comma form | 40 | +| Suffix | 39 | +| Four-plus tokens | 31 | + +Every initial output was assigned manually from the raw string under the +written rubric. Parser, model, and normalizer outputs were not shown to or used +by that initial labeling step. Ambiguous, corrupt, Chinese, and non-person rows +were excluded from gold rather than guessed. Review produced 1,053 usable gold +rows; a salted hash selected 1,000, then a separate salted hash fixed an 800/200 +development/holdout split. The remaining 53 rows were a development reserve. + +After the final evaluation exposed 23 ambiguous middle/surname boundaries, the +user explicitly requested a web-source adjudication of every mismatch. Six +manual labels were corrected from structured institutional or bibliographic +authority records. The remaining 18 boundaries are retained as semantic +benchmark differences; they are not treated as sufficient evidence to override +structured source roles. Each changed review row records its external source +URLs, and the full adjudication ledger is retained in ignored scratch artifacts. + +The selected JSONL has SHA-256 +`B367254A7282DA3B30A37B478A679202E5C65D465541A3A0E772AA649D30B8DE`. +It contains 977 high-confidence and 23 medium-confidence reviews. + +Raw S2-derived names and manual review files remain under ignored +`scratch/canonical_names/`; they are not committed because they contain +internal personal data with upstream redistribution constraints. The ACL +source's redistribution terms are also not documented locally. + +## Evaluation protocol + +Rules were iterated only against the 800-name development split and the +53-name reserve. The holdout was evaluated once after the tuning rules were +frozen. A later independent code review found specification-level edge cases +not represented in the gold packet; those fixes were developed only against +synthetic regression strings, without consulting holdout labels. The full +packet was then rerun as a regression check and the reported holdout score was +unchanged. Exact match requires the person outcome, canonical text, first, +middle, last, and suffix to all agree with the manual record. + +Reproduction commands, when the ignored source/review artifacts are present: + +```powershell +uv run python scratch\canonical_names\assemble_gold.py ` + --acl-packet scratch\canonical_names\packets\acl_pilot_60.jsonl ` + --s2-packet scratch\canonical_names\packets\s2_candidates_1300.jsonl ` + --reviews-dir scratch\canonical_names\reviews ` + --output scratch\canonical_names\gold\canonical_non_chinese_gold_1000.jsonl ` + --reserve-output scratch\canonical_names\gold\canonical_non_chinese_reserve.jsonl ` + --manifest scratch\canonical_names\gold\manifest.json ` + --size 1000 --holdout-size 200 + +uv run python scratch\canonical_names\evaluate_gold.py ` + --input scratch\canonical_names\gold\canonical_non_chinese_gold_1000.jsonl ` + --split all --limit 1000 ` + --report scratch\canonical_names\metrics\all_final.json ` + --mismatches scratch\canonical_names\metrics\all_final_mismatches.jsonl +``` + +## Boundary policy + +An undelimited raw string still cannot identify every middle/surname boundary +from shape alone. The normalizer therefore keeps the conservative final-token +surname rule, family-particle rules, and initial-shape rules as its default. +It does not use person-specific or authority-specific family-span exceptions. +Structured input preserves the supplied first/middle/last roles unless +mechanical cleanup empties a required boundary. Semantic boundary evaluation is +reported separately rather than changing that storage contract. diff --git a/pyproject.toml b/pyproject.toml index bdfb879..658acc4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "sinonym" -version = "0.3.1" +version = "0.4.0" description = "Chinese Name Detection and Normalization Module" readme = "README.md" requires-python = ">=3.10" @@ -67,6 +67,7 @@ include = [ "scripts/verify_multiprocess.py", "scripts/generate_surname_romanizations.py", "scripts/generate_name_statistics.py", + "scripts/build_east_asian_name_lexicons.py", "scripts/train_ml_classifier_for_chinese_vs_japanese.py", "README.md", "pyproject.toml", diff --git a/scripts/build_east_asian_name_lexicons.py b/scripts/build_east_asian_name_lexicons.py new file mode 100644 index 0000000..ef29e0c --- /dev/null +++ b/scripts/build_east_asian_name_lexicons.py @@ -0,0 +1,267 @@ +# ruff: noqa: INP001 +"""Build deterministic East Asian name-order lexicons from pinned open data.""" + +from __future__ import annotations + +import argparse +import csv +import gzip +import hashlib +import io +import json +import sys +import time +import unicodedata +import urllib.error +import urllib.request +from dataclasses import dataclass +from pathlib import Path + +COUNTRY_COMMIT = "eb62e13d4d62dd96cdfae79d293a02066352205f" +JAPANESE_COMMIT = "c5220278652e7bae05b06cfaf527f1b09a100de6" +USER_AGENT = "sinonym-east-asian-lexicon-builder/1.0" +MAX_ATTEMPTS = 4 +RETRYABLE_HTTP_CODES = frozenset({429, 502, 503, 504}) +MIN_GIVEN_FIELDS = 2 + + +@dataclass(frozen=True) +class Source: + """One hash-pinned upstream CSV.""" + + name: str + url: str + sha256: str + license: str + repository: str + + +SOURCES = ( + Source( + name="country_surnames", + url=( + "https://raw.githubusercontent.com/sigpwned/" + f"popular-names-by-country-dataset/{COUNTRY_COMMIT}/common-surnames-by-country.csv" + ), + sha256="32cb28bea558a9d353feeef097da03c6488a4e7ba9398e5983cee2f0b9caa91c", + license="CC0-1.0", + repository="https://github.com/sigpwned/popular-names-by-country-dataset", + ), + Source( + name="japanese_surnames", + url=( + "https://raw.githubusercontent.com/shuheilocale/" + f"japanese-personal-name-dataset/{JAPANESE_COMMIT}/" + "japanese_personal_name_dataset/dataset/last_name_org.csv" + ), + sha256="02706bd06932d6bde121e6fbaf9bc2a88c43e6f015128215eecb213223523d30", + license="MIT", + repository="https://github.com/shuheilocale/japanese-personal-name-dataset", + ), + Source( + name="japanese_male_given_names", + url=( + "https://raw.githubusercontent.com/shuheilocale/" + f"japanese-personal-name-dataset/{JAPANESE_COMMIT}/" + "japanese_personal_name_dataset/dataset/first_name_man_org.csv" + ), + sha256="c14a67047f6101fa3234e87c3f643f68ec1807f3fdb545855e57ab9bd5cd5a3b", + license="MIT", + repository="https://github.com/shuheilocale/japanese-personal-name-dataset", + ), + Source( + name="japanese_female_given_names", + url=( + "https://raw.githubusercontent.com/shuheilocale/" + f"japanese-personal-name-dataset/{JAPANESE_COMMIT}/" + "japanese_personal_name_dataset/dataset/first_name_woman_org.csv" + ), + sha256="de8ce6f8f430e14e0036ca209656a1ec06e9bcd83e9defffe1b0654dc636f9a9", + license="MIT", + repository="https://github.com/shuheilocale/japanese-personal-name-dataset", + ), +) + + +def parse_args() -> argparse.Namespace: + """Parse explicit output paths.""" + parser = argparse.ArgumentParser() + parser.add_argument("--roman-output", required=True, type=Path) + parser.add_argument("--native-output", required=True, type=Path) + return parser.parse_args() + + +def fetch(source: Source) -> bytes: + """Fetch and verify one pinned source with bounded retries.""" + for attempt in range(1, MAX_ATTEMPTS + 1): + request = urllib.request.Request(source.url, headers={"User-Agent": USER_AGENT}) # noqa: S310 + try: + with urllib.request.urlopen(request, timeout=60) as response: # noqa: S310 + payload = response.read() + except urllib.error.HTTPError as error: + if error.code not in RETRYABLE_HTTP_CODES or attempt == MAX_ATTEMPTS: + raise + delay_seconds = 5 * attempt + print( + f"retry source={source.name} attempt={attempt}/{MAX_ATTEMPTS} http={error.code} delay={delay_seconds}s", + file=sys.stderr, + ) + time.sleep(delay_seconds) + continue + digest = hashlib.sha256(payload).hexdigest() + if digest != source.sha256: + message = f"source hash mismatch for {source.name}: expected={source.sha256} actual={digest}" + raise ValueError(message) + return payload + message = f"bounded attempts exhausted for {source.name}" + raise AssertionError(message) + + +def fold(value: str) -> str: + """Return a lowercase accent-insensitive lookup key.""" + translated = value.translate(str.maketrans({"\u0110": "D", "\u0111": "d"})) + return "".join( + character for character in unicodedata.normalize("NFD", translated).casefold() if not unicodedata.combining(character) + ) + + +def japanese_roman_keys(value: str) -> set[str]: + """Return exact and common long-vowel-neutral Japanese keys.""" + exact = fold(value) + return {exact, exact.replace("ou", "o").replace("oo", "o").replace("uu", "u")} + + +def slash_variants(value: str) -> list[str]: + """Expand slash-separated source variants.""" + return [part.strip() for part in value.split("/") if part.strip()] + + +def is_hangul(value: str) -> bool: + """Return whether a source form is entirely modern Hangul.""" + return bool(value) and all("\uac00" <= character <= "\ud7a3" for character in value) + + +def source_metadata() -> list[dict[str, str]]: + """Return serializable provenance for every input.""" + return [ + { + "name": source.name, + "url": source.url, + "sha256": source.sha256, + "license": source.license, + "repository": source.repository, + } + for source in SOURCES + ] + + +def encode(payload: dict[str, object]) -> bytes: + """Return deterministic gzip-compressed JSON.""" + serialized = json.dumps( + payload, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + return gzip.compress(serialized, compresslevel=9, mtime=0) + + +def main() -> None: + """Build the two runtime assets and print their counts and hashes.""" + args = parse_args() + fetched = {source.name: fetch(source) for source in SOURCES} + country_rows = list( + csv.DictReader(io.StringIO(fetched["country_surnames"].decode("utf-8-sig"))), + ) + japanese_surname_rows = list( + csv.reader(io.StringIO(fetched["japanese_surnames"].decode("utf-8-sig"))), + ) + japanese_given_rows = [ + row + for source_name in ( + "japanese_male_given_names", + "japanese_female_given_names", + ) + for row in csv.reader(io.StringIO(fetched[source_name].decode("utf-8-sig"))) + if len(row) >= MIN_GIVEN_FIELDS and row[0].strip() and row[1].strip() + ] + + japanese_surnames_roman = sorted( + { + key + for row in japanese_surname_rows + if len(row) == 4 and row[3].strip() # noqa: PLR2004 + for key in japanese_roman_keys(row[3].strip()) + }, + ) + japanese_surnames_native = sorted( + { + row[0].strip() + for row in japanese_surname_rows + if len(row) == 4 and row[0].strip() # noqa: PLR2004 + }, + ) + japanese_given_roman = sorted( + {key for row in japanese_given_rows for key in japanese_roman_keys(row[1].strip())}, + ) + japanese_given_native = sorted( + {native.strip() for row in japanese_given_rows for native in row[2:] if native.strip()}, + ) + korean_surnames_roman = sorted( + { + fold(romanized) + for row in country_rows + if row["Country"] == "KR" and is_hangul(row["Localized Name"]) + for romanized in slash_variants(row["Romanized Name"]) + }, + ) + vietnamese_surnames_roman = sorted( + { + fold(romanized) + for row in country_rows + if row["Country"] == "VN" + for romanized in slash_variants(row["Romanized Name"]) + }, + ) + + provenance = source_metadata() + roman_payload: dict[str, object] = { + "schema_version": 1, + "sources": provenance, + "japanese_surnames": japanese_surnames_roman, + "japanese_given_names": japanese_given_roman, + "korean_surnames": korean_surnames_roman, + "vietnamese_surnames": vietnamese_surnames_roman, + } + native_payload: dict[str, object] = { + "schema_version": 1, + "sources": provenance, + "japanese_surnames": japanese_surnames_native, + "japanese_given_names": japanese_given_native, + } + outputs = ( + (args.roman_output, roman_payload), + (args.native_output, native_payload), + ) + report: dict[str, object] = {"counts": {}} + for path, payload in outputs: + path.parent.mkdir(parents=True, exist_ok=True) + encoded = encode(payload) + path.write_bytes(encoded) + report[path.name] = { + "bytes": len(encoded), + "sha256": hashlib.sha256(encoded).hexdigest(), + } + report["counts"] = { + "japanese_surnames_roman": len(japanese_surnames_roman), + "japanese_given_roman": len(japanese_given_roman), + "japanese_surnames_native": len(japanese_surnames_native), + "japanese_given_native": len(japanese_given_native), + "korean_surnames_roman": len(korean_surnames_roman), + "vietnamese_surnames_roman": len(vietnamese_surnames_roman), + } + print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/sinonym/chinese_names_data.py b/sinonym/chinese_names_data.py index ed3eeed..ec90d34 100644 --- a/sinonym/chinese_names_data.py +++ b/sinonym/chinese_names_data.py @@ -573,6 +573,10 @@ def _assert_no_duplicate_keys(*layers): "teo": ("zhang", "张"), # 张 - Teochew/Hokkien Teo = Mandarin Zhang "goh": ("wu", "吴"), # 吴 - Teochew/Hokkien Goh = Mandarin Wu "khoo": ("qiu", "邱"), # 邱 - Teochew/Hokkien Khoo = Mandarin Qiu + # Reviewed Taiwanese/Wade-Giles surname spellings. Conservative + # as-written frequency shares already live in surname_romanizations.csv. + "horng": ("hong", "洪"), + "hsien": ("xian", "冼/先"), "soo": ("su", "苏"), # 苏 - Korean Soo = Mandarin Su # Korean surnames with Chinese equivalents "jang": ("zhang", "张"), # 张 - Korean Jang = Mandarin Zhang @@ -583,6 +587,8 @@ def _assert_no_duplicate_keys(*layers): "kyeong": ("jing", "京"), # 京 - Korean Kyeong = Mandarin Jing } +ETHNICITY_CHINESE_SURNAME_ROMANIZATION_ALIASES = frozenset({"horng", "hsien"}) + # Sanity check: Ensure no inconsistencies between SYLLABLE_RULES and CANTONESE_SURNAMES def _check_surname_mapping_consistency(): @@ -1058,6 +1064,8 @@ def _check_surname_mapping_consistency(): # Korean name pairs: Common Korean given name combinations KOREAN_GIVEN_PAIRS = frozenset( { + ("in", "sun"), + ("jin", "uk"), ("soo", "jin"), # 수진 - very common Korean name ("min", "soo"), # 민수 - very common Korean name ("min", "jung"), # 민정 - very common Korean name @@ -1086,6 +1094,11 @@ def _check_surname_mapping_consistency(): }, ) +# Directional evidence only. ``Ok`` also occurs in Korean given names, so it +# must never be added to the anywhere-in-name Korean surname sets. +KOREAN_DIRECTIONAL_FAMILY_FIRST_SURNAMES = frozenset({"ok"}) +KOREAN_DIRECTIONAL_SINGLE_GIVEN_NAMES = frozenset({"jeung"}) + # Overlapping Korean surnames (exist in both Korean and Chinese) OVERLAPPING_KOREAN_SURNAMES = frozenset( @@ -1256,7 +1269,6 @@ def _check_surname_mapping_consistency(): }, ) - NAME_ORDER_ROUTING_COMMON_CHINESE_SURNAMES = frozenset( { "bai", diff --git a/sinonym/coretypes/__init__.py b/sinonym/coretypes/__init__.py index d40f77f..372005e 100644 --- a/sinonym/coretypes/__init__.py +++ b/sinonym/coretypes/__init__.py @@ -10,7 +10,9 @@ BatchFormatPattern, BatchParseResult, CacheInfo, + CanonicalName, IndividualAnalysis, + NameComponents, NameFormat, NameOrderEvidence, ParseCandidate, @@ -22,8 +24,10 @@ "BatchFormatPattern", "BatchParseResult", "CacheInfo", + "CanonicalName", "ChineseNameConfig", "IndividualAnalysis", + "NameComponents", "NameFormat", "NameOrderEvidence", "ParseCandidate", diff --git a/sinonym/coretypes/results.py b/sinonym/coretypes/results.py index 80426c2..62dfe2f 100644 --- a/sinonym/coretypes/results.py +++ b/sinonym/coretypes/results.py @@ -11,6 +11,44 @@ from enum import Enum +@dataclass(frozen=True) +class NameComponents: + """Assigned components and token lineage for a person's name. + + Component strings provide the convenient public representation while the + token tuples preserve the corresponding token boundaries. ``order`` records + component roles in display order; roles may repeat when tokens from the same + component are non-contiguous. + """ + + given_name: str = "" + middle_name: str = "" + surname: str = "" + suffix: str = "" + given_tokens: tuple[str, ...] = () + middle_tokens: tuple[str, ...] = () + surname_tokens: tuple[str, ...] = () + suffix_tokens: tuple[str, ...] = () + order: tuple[str, ...] = () + + +@dataclass(frozen=True) +class CanonicalName: + """Canonical representation of a person's name and its source components. + + ``source`` records the component assignment before normalization, while + ``normalized`` records the final component assignment used to render + ``text``. For structured input, surviving source roles remain authoritative; + normalization repairs roles only when mechanical cleanup empties a required + boundary. Both use immutable tuples so the complete value is immutable. + """ + + source_text: str + text: str + source: NameComponents + normalized: NameComponents + + @dataclass(frozen=True) class ParsedName: """Parsed name with surname and given name components. @@ -55,6 +93,8 @@ class ParseResult: parsed: ParsedName | None = None # Structured parsed components in the original input order parsed_original_order: ParsedName | None = None + # Canonical representation for person names, including non-Chinese names + canonical_name: CanonicalName | None = None @classmethod def success_with_name( @@ -63,6 +103,7 @@ def success_with_name( original_compound_surname: str | None = None, parsed: ParsedName | None = None, parsed_original_order: ParsedName | None = None, + canonical_name: CanonicalName | None = None, ) -> ParseResult: """Create a successful result with final formatted name. @@ -76,6 +117,7 @@ def success_with_name( original_compound_surname=original_compound_surname, parsed=parsed, parsed_original_order=parsed_original_order, + canonical_name=canonical_name, ) @classmethod @@ -84,6 +126,7 @@ def success_with_parse( surname_tokens: list[str], given_tokens: list[str], original_compound_surname: str | None = None, + canonical_name: CanonicalName | None = None, ) -> ParseResult: """Create a successful intermediate parse with raw tokens. @@ -104,11 +147,19 @@ def success_with_parse( original_compound_surname=original_compound_surname, parsed=parsed, parsed_original_order=None, + canonical_name=canonical_name, ) @classmethod - def failure(cls, error_message: str) -> ParseResult: - return cls(success=False, result="", error_message=error_message, original_compound_surname=None) + def failure(cls, error_message: str, canonical_name: CanonicalName | None = None) -> ParseResult: + """Create a failed Chinese-name parse with optional person metadata.""" + return cls( + success=False, + result="", + error_message=error_message, + original_compound_surname=None, + canonical_name=canonical_name, + ) def map(self, f) -> ParseResult: """Functor map operation - Scala-like transformation""" @@ -119,9 +170,10 @@ def map(self, f) -> ParseResult: self.original_compound_surname, self.parsed, self.parsed_original_order, + self.canonical_name, ) except Exception as e: # noqa: BLE001 - user-provided callbacks may raise arbitrary exceptions. - return ParseResult.failure(str(e)) + return ParseResult.failure(str(e), canonical_name=self.canonical_name) return self def flat_map(self, f) -> ParseResult: @@ -130,17 +182,21 @@ def flat_map(self, f) -> ParseResult: try: result = f(self.result) except Exception as e: # noqa: BLE001 - user-provided callbacks may raise arbitrary exceptions. - return ParseResult.failure(str(e)) + return ParseResult.failure(str(e), canonical_name=self.canonical_name) else: - # Preserve the original compound surname if the result doesn't already have one - if result.success and result.original_compound_surname is None: + preserve_original_compound = result.success and result.original_compound_surname is None + preserve_canonical_name = result.canonical_name is None and self.canonical_name is not None + if preserve_original_compound or preserve_canonical_name: return ParseResult( - result.success, - result.result, - result.error_message, - self.original_compound_surname, - result.parsed, - result.parsed_original_order, + success=result.success, + result=result.result, + error_message=result.error_message, + original_compound_surname=( + self.original_compound_surname if preserve_original_compound else result.original_compound_surname + ), + parsed=result.parsed, + parsed_original_order=result.parsed_original_order, + canonical_name=self.canonical_name if preserve_canonical_name else result.canonical_name, ) return result return self diff --git a/sinonym/data/EAST_ASIAN_NAME_LEXICONS.md b/sinonym/data/EAST_ASIAN_NAME_LEXICONS.md new file mode 100644 index 0000000..d7d1ce7 --- /dev/null +++ b/sinonym/data/EAST_ASIAN_NAME_LEXICONS.md @@ -0,0 +1,34 @@ +# East Asian name-order lexicon notices + +The runtime assets `east_asian_roman_lexicons.json.gz` and +`japanese_native_lexicons.json.gz` are deterministic derived lookup lists used +only by the conservative Japanese, Korean, and Vietnamese family-first router. +They contain component names, not complete people. + +## Japanese Personal Name Dataset + +Source: + +Pinned commit: `c5220278652e7bae05b06cfaf527f1b09a100de6` + +Copyright (c) 2022 shuheilocale. Distributed under the MIT License. The source +repository's `LICENSE` file contains the full notice and permission terms. + +The derived assets use the repository's surname and male/female given-name CSV +files. The build script records and verifies the SHA-256 of every input. + +## Popular Names by Country Dataset + +Source: + +Pinned commit: `eb62e13d4d62dd96cdfae79d293a02066352205f` + +Distributed under CC0-1.0. The derived assets use only the Korean and +Vietnamese surname rows whose provenance is recorded in the generated JSON. + +## Excluded research source + +JMnedict/ENAMDICT was evaluated in the scratch experiment but is deliberately +not included in these package assets. Its EDRDG/CC BY-SA terms include +attribution and ongoing data-update requirements that are outside this static +asset's release contract. diff --git a/sinonym/data/README.md b/sinonym/data/README.md index 82216d5..efc6952 100644 --- a/sinonym/data/README.md +++ b/sinonym/data/README.md @@ -4,6 +4,19 @@ This directory contains the data files and trained models used by the Sinonym li ## Data Files +### Conservative East Asian name-order lexicons + +- **`east_asian_roman_lexicons.json.gz`**: sorted Romanized Japanese surname + and given-name keys plus Korean and Vietnamese surname keys. +- **`japanese_native_lexicons.json.gz`**: sorted Japanese native-script surname + and given-name forms used to score compact-name boundaries. + +These assets contain component names rather than complete people. They are +generated deterministically by `scripts/build_east_asian_name_lexicons.py` +from hash-pinned MIT and CC0 sources. Full attribution, source commits, and the +reason the broader research-only JMnedict data is not bundled are recorded in +`EAST_ASIAN_NAME_LEXICONS.md`. + ### Name Frequency Data - **`familyname_orcid.csv`**: Chinese surnames with frequency data (parts per million) derived from ORCID records. Contains the most common Chinese surnames like 王, 李, 张, etc., with their frequency statistics. - **`givenname_orcid.csv`**: Chinese given name characters with usage statistics from ORCID records. Includes character, pinyin romanization, and frequency (ppm). diff --git a/sinonym/data/east_asian_roman_lexicons.json.gz b/sinonym/data/east_asian_roman_lexicons.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..04c375094df17df182be741bd935711dd1590df7 GIT binary patch literal 30052 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 L$uf8x5wrmSfcZXO literal 0 HcmV?d00001 diff --git a/sinonym/data/japanese_native_lexicons.json.gz b/sinonym/data/japanese_native_lexicons.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..01e92ceb8596c63031449e775bc2cdbc761796cf GIT binary patch literal 210081 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

XE*1Y_&x2+oxWW22~Fl|8PZ#1$% zF&6(>GWU)A`(vH8zh9;P#2!43D|5}bjIcVY0)x@HP^zHje?QPVXS0k_L&=p2^gY|8 zWz8PycJo%YRDt&2#Olus)w6;viwhLqDA2Q3TW^%yV_q{m@A0<6x;M^odSrS8!E0mO zMh+QrBp5&O#u}c@s)EMFztbKIy4k_U$CWxL#e4rj|Con-=97X`;is@~&19-g!3_mX zRN@bmYLoqH22s6LeN1}P^~K6PW`(2iKLcdqp&!3ekLE~L2V#6Z;l%+n{ z9Vdd170<_Tv;lA0Xl#;o1C!m?w_EFD;OFfzhiv55_(EhkY zN{ahz~Ug$Zu@wYmxDLtIFIYQ!7Q>Vv6HvDhO{#(pfEig~5n-&s3LPmLzXNO05 zfcNL!_Li;i~C#LJ1ddW5)qLY%Z%Ll+cR-y->?5n)AUHZT{SMpSkh zKJkMoyhU$$B^!b-1*2xZ7ffksW;k8|BB`)(H+bmDqQR8g z(D0|hBJd#xq>2!@?~r(A{BRLGfr~9*b%@pa=<6(CwjV6uZzfLcAO5G9PQgF4i@gWr zKm`^Pw~YqD62@L%NPYhd#L~H~RR^r20X6l|-Is3H`ijE4D+FFMk*K3cq{C1XDd@5Y zmzz52oj$UYRi5XlRpbBbf##(aO$z$(PdySW#I80>9hCMW?jC_GUK}49 z8HC)@L0ke@S*l_ymn_kNW)uFir!)lJ|-K_)z0E^ZbC(HMMut zELy)F&}g4*=DH*=()mv_^!Kb0N>>?}(5>o=;RAt_LiEI z2@jBbx|>HhG)3DzTn4mLIrilJG%R33+Lk51j^1p9916YVH z;y9UvoN+M48Kn>d@6kQpuxOF~W?JY|sD4SzzT2ZZFD=dWGG&+0s8_S;wzzBX~X#MMKR&zW}i`oxYJQo*t#d4+UNO)1inbD6c^=W6&8N-(5uzwm2K zl-;`#zFVDJsh#vMA0EArjRcN+ov!b;Ks+WQc`VQQ_lwsQaEj$TC#FpmMIY&FdbovD zrpQ;4HUi%PeBNFbOjoT;Ii9AHr`-i_G;K7&v`}>k&F`L~K6+P{Z_Jky*+o4_*Z{u4 zK;JY#U*w_1TVI04`>8Qb5#y4liDY7US;!yMTu##PHJ(eLggM3kwoOx7pMv^4K0QN4 ze^4e$x!nZ+*B!j^QbB}b7Nq&aP{orNiZq2fIB4hwI> zvZb??JkWe~4Gq@z3Gb7rlpLT;Y4OLj1E+A^Mt6vKByAJG?E?^tju+GKlHd-&l@hJmF(>6;3G9puI`ikhfPZ6wbFS&I=WE(laA~;1&ivg*s?} zxxef{c-RFsH{T>94d7WNOYCa*TFO~RSb$)u3VHg)v(2UH zEZsGnBhADijsH3`igHPJV_ieqG#?DnRB#hOvFzxP4_M+BV*X0|r*y@X@sVYf!stSg z-Ks?zpi4hBsakWC(o9o``6!3Y0f#}RB8kDMA*otxlu}N=QwmElDydp})KiiOP&F!S zvX!0drL7}X@7yokwjuQhMj=`8mdMwX_D6nnDMpCo8p~VCcNe&)obyM;Ks_> z%pUV{BlClBe-eK8-|;?|0=J5A(gE2D*a#IJ=P5&2*yir0k8GzmdXiMlJ}N<`haAG} z`>-qVqogYa>5cS~+>5Nkxd!P|D7VS$15=F~)cn0-`;}9ZHbU={m`b!pLp~&noc+mu zF}R_cwCn{@MZkY5p;>RQ~K0Y+>3(xhX==K zR<1c#3OeaiiqXSpNE$ZUr8B(w~(i5~%Kzl2l1P>M633FlTlu#O>2z5mlVQiE>$ z#1qDGT+!0y7w%8u;BL0={x5a>nHEL$P{}a>#QNI zw_>2t35@TU&rvZxeMRXRY}ZoM~$cQ6w){UjDeLc1pLBn zd}hSBF|JpTGkxWh?88q;Z*kONiD9%xYq3Uaqo*vHtjy5q^#n^uiAkE;@+nQrkeq0Ent5+E=iu9OYAD<788o&yQ90{!)2k9Gfxe_F7iJ#c+= zq%eSSc2IA?umZJ&c!u59FE4e~_#xown057Ko?VPTdS~f8!Rr8lpMJ?y;C~hKfcy2p zn-v|cy$oYx%&ipE4yXz67)xzF(pp^ht!UhP^7FsP-M6Q1-vFyvg?)^THwU;YQN0Xv zDny+vut25l4`q!2Xhi|HqIY4d`WrVd=PZ852Ejv^1oTNX2S|ye3pv8W?)p|VpA7AWFH1+N zX8);@xHL$LCY4VNDqEBoWf2-Yuud$>|4#nsvdnsYJl~pMs@GiTwhevevPrZ2rd&C` zpI(xo=X+#tUB>S(+%3Tw%kqPM6U9wL#G9YZvwgMcoox>|UoMO@$&W6?i4LgAPA3Pv z6Q;4eGVP69jns;{+B+1oXkY(;T;2*#9~$o0+qe^+BBQFmfLL+{tFGz_Mfhh~@u2_d zeJTOiUs(f?G`Pxx8IfFYxXv=t6CumcmEN#6Hz zD=^GcrT=;McV6bbpR4Ri&r%^yBQl{`Kx)=nsPp5I5<+VNS~x{LFSg-UVd+a>YEom0 zld$%RKmIlz@5Y0^p`a5_Xq8=`L3+qe0zxT7))g2@Gbtcrd-++HOJ8W(B`cT(KBWTE zBijqcSTA)i@|C=MfA~;GVU`USeFG%#oL0UN4l_ZCw)1EWRJdWFK%A|@bn|(se^&YJVB^1S3pN{XA!1}y~b+aZoT0#VXWD0OHL_+ zEffoKAcpWsal(b0zRRz65%oUuimzz7ks{~ੑ&yvKB5>RY(T1;cOz5D_dg@+Qb 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%0D?-f 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 ParseResult | None: return None return ParseResult.failure(non_person_reason) - def normalize_name(self, raw_name: str) -> ParseResult: + def _normalize_chinese_name(self, raw_name: str) -> ParseResult: """ - Main API method: Detect if a name is Chinese and normalize it. + Run the legacy Chinese detection and normalization pipeline unchanged. Returns ParseResult with: - success=True, result=formatted_name if Chinese name detected @@ -856,6 +871,8 @@ def normalize_name(self, raw_name: str) -> ParseResult: if initial_failure is not None: return initial_failure + raw_name = self._chinese_classification_input(raw_name) + # Use new normalization service for cleaner pipeline normalized_input = self._normalizer.apply(raw_name) @@ -865,6 +882,13 @@ def normalize_name(self, raw_name: str) -> ParseResult: # Check if this is an all-Chinese input first is_all_chinese = self._normalizer._text_preprocessor.is_all_chinese_input(raw_name) + # Exact alternating Roman/Han alignment is stronger evidence than the + # Roman-only ethnicity gate, especially for polyphonic Han surnames. + if self._config.cjk_pattern.search(raw_name) and self._config.ascii_alpha_pattern.search(raw_name): + aligned_bilingual_result = self._normalize_aligned_bilingual_name(normalized_input) + if aligned_bilingual_result is not None: + return aligned_bilingual_result + # Check for non-Chinese ethnicity using normalized tokens (consistent for all inputs) non_chinese_result = self._ethnicity_service.classify_ethnicity( normalized_input.roman_tokens, @@ -875,9 +899,9 @@ def normalize_name(self, raw_name: str) -> ParseResult: if non_chinese_result.success is False: return non_chinese_result - aligned_bilingual_result = self._normalize_aligned_bilingual_name(normalized_input) - if aligned_bilingual_result is not None: - return aligned_bilingual_result + contextual_taiwan_result = self._normalize_contextual_taiwan_name(normalized_input) + if contextual_taiwan_result is not None: + return contextual_taiwan_result compact_han_roman_result = self._normalize_compact_han_roman_name(normalized_input) if compact_han_roman_result is not None: @@ -1136,6 +1160,265 @@ def normalize_name(self, raw_name: str) -> ParseResult: return ParseResult.failure("name not recognised as Chinese") + def _normalize_contextual_taiwan_name(self, normalized_input: NormalizedInput) -> ParseResult | None: + """Parse the narrowly admitted Taiwan romanization contexts.""" + given_parts = self._ethnicity_service.contextual_taiwan_given_parts(normalized_input.roman_tokens) + if given_parts is None: + return None + + norm_map = dict(normalized_input.norm_map) + # BOCA's ``Jr`` corresponds to the Mandarin syllable ``Zhi``. Keep the + # visible Taiwan spelling while supplying internal given-name evidence. + if given_parts == ("tsung", "jr"): + norm_map["jr"] = "zhi" + contextual_input = replace(normalized_input, norm_map=norm_map) + try: + return self._format_parse_result( + [normalized_input.roman_tokens[-1]], + list(given_parts), + contextual_input, + ["given", "surname"], + ) + except ValueError as error: + return ParseResult.failure(str(error)) + + def _chinese_classification_input(self, raw_name: str) -> str: + """Remove only an audited leading ``Et al.`` citation contaminant.""" + prefix = raw_name.lstrip() + marker = "et al." + if len(prefix) <= len(marker) or prefix[: len(marker)].casefold() != marker or not prefix[len(marker)].isspace(): + return raw_name + normalized = self._person_name_normalizer.normalize_text(raw_name) + if normalized.canonical_name is None or len(normalized.dropped_tokens) < LEADING_ET_AL_TOKEN_COUNT: + return raw_name + first, second = normalized.dropped_tokens[:LEADING_ET_AL_TOKEN_COUNT] + has_exact_prefix = ( + first.text.casefold() == "et" + and second.text.casefold() == "al." + and first.reason is DropReason.CONNECTOR + and second.reason is DropReason.CONNECTOR + ) + return normalized.canonical_name.text if has_exact_prefix else raw_name + + @staticmethod + def _canonical_components_from_parsed(parsed: ParsedName) -> NameComponents: + """Convert legacy parsed components to immutable canonical components.""" + counts = { + "given": len(parsed.given_tokens), + "middle": len(parsed.middle_tokens), + "surname": len(parsed.surname_tokens), + } + occurrences = {role: parsed.order.count(role) for role in counts} + expanded_order: list[str] = [] + for role in parsed.order: + count = counts.get(role, 0) + if occurrences.get(role) == 1: + expanded_order.extend([role] * count) + elif count: + expanded_order.append(role) + return NameComponents( + given_name=parsed.given_name, + middle_name=parsed.middle_name, + surname=parsed.surname, + given_tokens=tuple(parsed.given_tokens), + middle_tokens=tuple(parsed.middle_tokens), + surname_tokens=tuple(parsed.surname_tokens), + order=tuple(expanded_order), + ) + + def _canonical_name_from_chinese_result(self, raw_name: str, result: ParseResult) -> CanonicalName | None: + """Build canonical metadata from the final selected Chinese parse.""" + if not result.success or result.parsed is None or not isinstance(result.result, str): + return None + normalized = self._canonical_components_from_parsed(result.parsed) + source_parsed = result.parsed_original_order or result.parsed + source = self._canonical_source_components(raw_name, source_parsed, normalized) + return CanonicalName( + source_text=raw_name, + text=result.result, + source=source, + normalized=normalized, + ) + + @staticmethod + def _component_token_key(token: str) -> str: + """Return a comparison key for source-to-normalized token lineage.""" + return "".join(character.casefold() for character in token if character.isalnum()) + + @staticmethod + def _ordered_component_tokens(components: NameComponents) -> list[str]: + """Rebuild a component token stream from its positional role order.""" + queues = { + "given": iter(components.given_tokens), + "middle": iter(components.middle_tokens), + "surname": iter(components.surname_tokens), + "suffix": iter(components.suffix_tokens), + } + return [next(queues[role]) for role in components.order] + + def _canonical_source_components( + self, + raw_name: str, + parsed_original: ParsedName, + normalized: NameComponents, + ) -> NameComponents: + """Preserve raw Latin token boundaries while retaining parsed roles.""" + source_result = self._person_name_normalizer.normalize_text(raw_name) + if source_result.canonical_name is None: + return self._canonical_components_from_parsed(parsed_original) + + generic_source = source_result.canonical_name.source + ordered_tokens = self._ordered_component_tokens(generic_source) + normalized_by_role = { + "given": normalized.given_tokens, + "middle": normalized.middle_tokens, + "surname": normalized.surname_tokens, + "suffix": normalized.suffix_tokens, + } + role_keys = { + role: { + *(self._component_token_key(token) for token in tokens), + self._component_token_key("".join(tokens)), + } + for role, tokens in normalized_by_role.items() + if tokens + } + fallback_roles = [role for role in parsed_original.order if role in normalized_by_role] + assigned: list[tuple[str, str]] = [] + for index, token in enumerate(ordered_tokens): + key = self._component_token_key(token) + candidates = [role for role, keys in role_keys.items() if key and key in keys] + fallback = fallback_roles[index] if index < len(fallback_roles) else "given" + role = candidates[0] if len(candidates) == 1 else fallback + assigned.append((role, token)) + + tokens_by_role = { + role: tuple(token for assigned_role, token in assigned if assigned_role == role) for role in normalized_by_role + } + return NameComponents( + given_name=" ".join(tokens_by_role["given"]), + middle_name=" ".join(tokens_by_role["middle"]), + surname=" ".join(tokens_by_role["surname"]), + suffix=" ".join(tokens_by_role["suffix"]), + given_tokens=tokens_by_role["given"], + middle_tokens=tokens_by_role["middle"], + surname_tokens=tokens_by_role["surname"], + suffix_tokens=tokens_by_role["suffix"], + order=tuple(role for role, _token in assigned), + ) + + def normalize_person_name(self, raw_name: str) -> CanonicalName | None: + """Return a canonical name for one person-like raw string. + + This method is independent of Chinese recognition and returns ``None`` + for invalid or obvious non-person inputs. + """ + if not raw_name or len(raw_name) > self._config.max_name_length: + return None + if all(character in string.punctuation + string.whitespace for character in raw_name): + return None + self._ensure_initialized() + if self._non_person_input_service is not None: + non_person_reason = self._non_person_input_service.failure_reason(raw_name) + if non_person_reason is not None: + return None + normalized = self._person_name_normalizer.normalize_text(raw_name) + if normalized.outcome is not PersonNameOutcome.PERSON or normalized.canonical_name is None: + return None + routed = self._canonical_name_from_east_asian_order(raw_name, normalized.canonical_name) + return routed or normalized.canonical_name + + def _canonical_name_from_east_asian_order( + self, + raw_name: str, + baseline: CanonicalName, + ) -> CanonicalName | None: + """Return a conservatively family-first canonical name or abstain.""" + if self._ethnicity_service is None: + return None + try: + decision = self._east_asian_name_order.infer( + raw_name, + japanese_probability=self._ethnicity_service.japanese_probability, + ) + except RuntimeError as error: + LOGGER.warning( + "East Asian name-order routing abstained after classifier failure for %r: %s", + raw_name, + error, + ) + return None + if decision is None: + return None + return self._canonical_name_from_order_decision(baseline, decision) + + def _canonical_name_from_order_decision( + self, + baseline: CanonicalName, + decision: EastAsianNameOrderDecision, + ) -> CanonicalName: + """Normalize routed components while retaining original source-role lineage.""" + routed = self._person_name_normalizer.normalize_components( + first_name=decision.first_name, + middle_name=decision.middle_name, + last_name=decision.last_name, + ) + if routed.outcome is not PersonNameOutcome.PERSON or routed.canonical_name is None: + message = f"East Asian route {decision.reason!r} produced invalid components" + raise RuntimeError(message) + return replace( + routed.canonical_name, + source_text=baseline.source_text, + source=decision.source_components(), + ) + + def normalize_person_name_components( + self, + *, + first_name: str | None = None, + middle_name: str | None = None, + last_name: str | None = None, + suffix: str | None = None, + ) -> CanonicalName | None: + """Normalize structured fields, including conservative family-first routing. + + The normalized assignment may follow high-confidence East Asian order + evidence, while ``source`` retains the caller-provided roles and order. + """ + normalized = self._person_name_normalizer.normalize_components( + first_name=first_name, + middle_name=middle_name, + last_name=last_name, + suffix=suffix, + ) + if normalized.outcome is not PersonNameOutcome.PERSON or normalized.canonical_name is None: + return None + baseline = normalized.canonical_name + routed = self._canonical_name_from_east_asian_order(baseline.source_text, baseline) + if routed is None: + return baseline + return replace(routed, source=baseline.source) + + def _attach_canonical_name(self, raw_name: str, result: ParseResult) -> ParseResult: + """Attach canonical metadata without changing legacy recognition fields.""" + if result.canonical_name is not None: + return result + canonical_name = self._canonical_name_from_chinese_result(raw_name, result) + if canonical_name is None: + canonical_name = self.normalize_person_name(raw_name) + if canonical_name is None: + return result + return replace(result, canonical_name=canonical_name) + + def normalize_name(self, raw_name: str) -> ParseResult: + """Detect/normalize Chinese names and surface canonical data for people. + + Legacy ``success``, ``result``, ``parsed``, and error semantics remain + Chinese-specific. ``canonical_name`` is the all-person representation. + """ + result = self._normalize_chinese_name(raw_name) + return self._attach_canonical_name(raw_name, result) + # Backwards compatibility alias def is_chinese_name(self, raw_name: str) -> ParseResult: # pragma: no cover - thin wrapper """Deprecated: use normalize_name(). Maintained for compatibility.""" @@ -1175,7 +1458,7 @@ def analyze_name_batch( individual_results = [self.normalize_name(name) for name in names] return self._create_fallback_batch_result(names, individual_results) - return self._batch_analysis_service.analyze_name_batch( + batch_result = self._batch_analysis_service.analyze_name_batch( names, self._normalizer, self._formatting_service, @@ -1184,6 +1467,11 @@ def analyze_name_batch( format_threshold=format_threshold, ), ) + canonical_results = [ + self._attach_canonical_name(name, result) + for name, result in zip(batch_result.names, batch_result.results, strict=True) + ] + return replace(batch_result, results=canonical_results) def detect_batch_format( self, diff --git a/sinonym/pipeline/name_order_routing.py b/sinonym/pipeline/name_order_routing.py index 77d45c1..884d5f5 100644 --- a/sinonym/pipeline/name_order_routing.py +++ b/sinonym/pipeline/name_order_routing.py @@ -17,9 +17,9 @@ abstain + "unknown" as a contract violation and raise. - PP-only regime: there is no second run to pick from; emit the as-typed reading via `input_order_parsed(result)` (trailing token is the surname, everything - else keeps its position), except spaced Han surname-first rows, where source - spacing already marks the surname boundary and the PP parse is the input-order - reading. If the as-typed reading cannot be materialized (e.g. the + else keeps its position), except spaced Han surname-first rows and internal + compound-surname spans, where the PP parse preserves the established surname + boundary. If the as-typed reading cannot be materialized (e.g. the original-order parse ends in a middle initial), the abstain surfaces as an explicit failure (no parsed person) — never fall back to the reordered PP parse the abstain declined to trust. Do NOT re-parse the name standalone: the @@ -847,8 +847,9 @@ def pp_abstain_parsed(result: ParseResult, route_row: Row) -> ParsedName | None: """Return the final parsed person components for a PP-only abstain row. Latin-only abstain keeps the as-typed given-first reading. Spaced Han rows - guarded by `spaced_cjk_zero_batch_surname_first` already have a source - surname boundary, so the PP parse is the stable input-order reading. + guarded by `spaced_cjk_zero_batch_surname_first` and corrected internal + compound-surname spans already have a stable surname boundary, so the PP + parse is the stable component reading. Returns None when the as-typed reading cannot be materialized (e.g. the original-order parse ends in a middle initial): an abstain must surface as an explicit failure, never fall back to the reordered PP parse it declined @@ -964,7 +965,13 @@ def _pp_abstain_failed_parse(row: Row) -> bool: def _pp_abstain_uses_pp_parse_for_abstain(row: Row) -> bool: - return row.get("router_reason") == "spaced_cjk_zero_batch_surname_first" + if row.get("router_reason") == "spaced_cjk_zero_batch_surname_first": + return True + return ( + row.get("selected_surname_position") == "internal" + and "selected_surname_token_count" in row + and _required_float(row, "selected_surname_token_count") > 1 + ) def _pp_abstain_predicates(row: Row) -> dict[str, bool]: diff --git a/sinonym/services/__init__.py b/sinonym/services/__init__.py index caab591..17fa812 100644 --- a/sinonym/services/__init__.py +++ b/sinonym/services/__init__.py @@ -15,6 +15,13 @@ from sinonym.services.non_person import NonPersonInputDetectionService from sinonym.services.normalization import LazyNormalizationMap, NormalizationService, NormalizedInput from sinonym.services.parsing import NameParsingService +from sinonym.services.person_name_normalization import ( + DroppedNameToken, + DropReason, + PersonNameNormalizationResult, + PersonNameNormalizationService, + PersonNameOutcome, +) class ServiceContext: @@ -37,6 +44,8 @@ def __init__(self, config, normalizer, data): "CacheInfo", "ChineseNameConfig", "DataInitializationService", + "DropReason", + "DroppedNameToken", "EthnicityClassificationService", "LazyNormalizationMap", # Data structures @@ -47,6 +56,9 @@ def __init__(self, config, normalizer, data): "NormalizationService", "NormalizedInput", "ParseResult", + "PersonNameNormalizationResult", + "PersonNameNormalizationService", + "PersonNameOutcome", # Services "PinyinCacheService", "ServiceContext", diff --git a/sinonym/services/cache.py b/sinonym/services/cache.py index 3516253..b90497d 100644 --- a/sinonym/services/cache.py +++ b/sinonym/services/cache.py @@ -7,6 +7,7 @@ from __future__ import annotations from functools import cache +from itertools import product from typing import TYPE_CHECKING import pypinyin @@ -20,6 +21,13 @@ def _char_to_pinyin(ch: str) -> str: return pypinyin.lazy_pinyin(ch, style=pypinyin.Style.NORMAL)[0] +@cache # one entry per unique Han character +def _char_to_pinyin_alternatives(ch: str) -> tuple[str, ...]: + """Return every dictionary pronunciation for one Han character.""" + readings = pypinyin.pinyin(ch, style=pypinyin.Style.NORMAL, heteronym=True)[0] + return tuple(dict.fromkeys(readings)) + + class PinyinCacheService: """ * deterministic, thread‑safe, O(1) repeated look‑ups @@ -34,6 +42,10 @@ def han_to_pinyin_fast(self, han_str: str) -> list[str]: """Return pinyin for every character, memoising on first sight.""" return [_char_to_pinyin(c) for c in han_str] + def han_to_pinyin_alternatives_fast(self, han_str: str) -> tuple[tuple[str, ...], ...]: + """Return pronunciation combinations for an explicitly aligned Han token.""" + return tuple(product(*(_char_to_pinyin_alternatives(character) for character in han_str))) + # (optional) diagnostics you were using elsewhere @property def cache_size(self) -> int: diff --git a/sinonym/services/east_asian_name_order.py b/sinonym/services/east_asian_name_order.py new file mode 100644 index 0000000..a11d3be --- /dev/null +++ b/sinonym/services/east_asian_name_order.py @@ -0,0 +1,354 @@ +"""Conservative family-first routing for Japanese, Korean, and Vietnamese names.""" + +from __future__ import annotations + +import gzip +import json +import re +import unicodedata +from bisect import bisect_left +from dataclasses import dataclass +from functools import lru_cache +from itertools import pairwise +from typing import TYPE_CHECKING, Any + +from sinonym.chinese_names_data import ( + KOREAN_AMBIGUOUS_PATTERNS, + KOREAN_GIVEN_PATTERNS, + KOREAN_ONLY_SURNAMES, + KOREAN_SPECIFIC_PATTERNS, + NAME_ORDER_ROUTING_KOREAN_GIVEN_SYLLABLES, + NAME_ORDER_ROUTING_KOREAN_SURNAMES, + OVERLAPPING_KOREAN_SURNAMES, + VIETNAMESE_ONLY_SURNAMES, +) +from sinonym.coretypes import NameComponents +from sinonym.resources import read_bytes + +if TYPE_CHECKING: + from collections.abc import Callable + +JAPANESE_ML_THRESHOLD = 0.8 +KOREAN_NATIVE_TOKEN_LENGTH = 3 +MAX_ROMANIZED_TOKENS = 5 +MAX_KOREAN_ROMANIZED_TOKENS = 3 +MIN_ROMANIZED_TOKENS = 2 +ROMAN_ASSET = "east_asian_roman_lexicons.json.gz" +NATIVE_ASSET = "japanese_native_lexicons.json.gz" +WHITESPACE_RE = re.compile(r"\s+") + + +@dataclass(frozen=True) +class EastAsianNameOrderDecision: + """One high-confidence semantic component assignment.""" + + surface: str + given_tokens: tuple[str, ...] + middle_tokens: tuple[str, ...] + surname_tokens: tuple[str, ...] + source_order: tuple[str, ...] + reason: str + + @property + def first_name(self) -> str: + """Return the semantic given component.""" + return " ".join(self.given_tokens) + + @property + def middle_name(self) -> str: + """Return the semantic middle component.""" + return " ".join(self.middle_tokens) + + @property + def last_name(self) -> str: + """Return the semantic surname component.""" + return " ".join(self.surname_tokens) + + def source_components(self) -> NameComponents: + """Return source-role lineage in the original family-first order.""" + return NameComponents( + given_name=self.first_name, + middle_name=self.middle_name, + surname=self.last_name, + suffix="", + given_tokens=self.given_tokens, + middle_tokens=self.middle_tokens, + surname_tokens=self.surname_tokens, + suffix_tokens=(), + order=self.source_order, + ) + + +@dataclass(frozen=True) +class _RomanLexicons: + japanese_surnames: tuple[str, ...] + japanese_given_names: tuple[str, ...] + korean_surnames: tuple[str, ...] + vietnamese_surnames: tuple[str, ...] + + +@dataclass(frozen=True) +class _NativeLexicons: + japanese_surnames: tuple[str, ...] + japanese_given_names: tuple[str, ...] + + +def _load_payload(name: str) -> dict[str, Any]: + """Load one strict gzip JSON package resource.""" + payload = json.loads(gzip.decompress(read_bytes(name)).decode("utf-8")) + if not isinstance(payload, dict) or payload.get("schema_version") != 1: + message = f"unsupported East Asian lexicon schema in {name}" + raise ValueError(message) + return payload + + +def _validated_values(payload: dict[str, Any], key: str, asset: str) -> tuple[str, ...]: + """Validate one sorted, unique string list at the package boundary.""" + values = payload.get(key) + if not isinstance(values, list) or not all(isinstance(value, str) and value for value in values): + message = f"invalid {key} in {asset}" + raise ValueError(message) + if any(left >= right for left, right in pairwise(values)): + message = f"{key} must be sorted and unique in {asset}" + raise ValueError(message) + return tuple(values) + + +@lru_cache(maxsize=1) +def _roman_lexicons() -> _RomanLexicons: + payload = _load_payload(ROMAN_ASSET) + external_korean = _validated_values(payload, "korean_surnames", ROMAN_ASSET) + korean = sorted( + set(external_korean) | NAME_ORDER_ROUTING_KOREAN_SURNAMES | KOREAN_ONLY_SURNAMES | OVERLAPPING_KOREAN_SURNAMES, + ) + vietnamese = sorted( + set(_validated_values(payload, "vietnamese_surnames", ROMAN_ASSET)) + | {_fold(value) for value in VIETNAMESE_ONLY_SURNAMES}, + ) + return _RomanLexicons( + japanese_surnames=_validated_values(payload, "japanese_surnames", ROMAN_ASSET), + japanese_given_names=_validated_values(payload, "japanese_given_names", ROMAN_ASSET), + korean_surnames=tuple(korean), + vietnamese_surnames=tuple(vietnamese), + ) + + +@lru_cache(maxsize=1) +def _native_lexicons() -> _NativeLexicons: + payload = _load_payload(NATIVE_ASSET) + return _NativeLexicons( + japanese_surnames=_validated_values(payload, "japanese_surnames", NATIVE_ASSET), + japanese_given_names=_validated_values(payload, "japanese_given_names", NATIVE_ASSET), + ) + + +def _contains(values: tuple[str, ...], key: str) -> bool: + index = bisect_left(values, key) + return index < len(values) and values[index] == key + + +def _fold(value: str) -> str: + translated = value.translate(str.maketrans({"\u0110": "D", "\u0111": "d"})) + return "".join( + character for character in unicodedata.normalize("NFD", translated).casefold() if not unicodedata.combining(character) + ) + + +def _japanese_roman_keys(value: str) -> tuple[str, ...]: + exact = _fold(value) + collapsed = exact.replace("ou", "o").replace("oo", "o").replace("uu", "u") + return (exact,) if exact == collapsed else (exact, collapsed) + + +def _contains_any(values: tuple[str, ...], keys: tuple[str, ...]) -> bool: + return any(_contains(values, key) for key in keys) + + +def _is_han(character: str) -> bool: + return "\u3400" <= character <= "\u4dbf" or "\u4e00" <= character <= "\u9fff" or "\uf900" <= character <= "\ufaff" + + +def _is_kana(character: str) -> bool: + return "\u3040" <= character <= "\u30ff" or "\u31f0" <= character <= "\u31ff" + + +def _is_hangul(value: str) -> bool: + return bool(value) and all("\uac00" <= character <= "\ud7a3" for character in value) + + +def _is_compact_japanese(value: str) -> bool: + return bool(value) and " " not in value and all(_is_han(character) or _is_kana(character) for character in value) + + +def _han_to_kana_boundary(value: str) -> int | None: + index = 0 + while index < len(value) and _is_han(value[index]): + index += 1 + if index and index < len(value) and all(_is_kana(character) for character in value[index:]): + return index + return None + + +class EastAsianNameOrderService: + """Infer only the family-first cases supported by conservative evidence.""" + + def infer( + self, + raw_name: str, + *, + japanese_probability: Callable[[str], float], + ) -> EastAsianNameOrderDecision | None: + """Return semantic components or abstain without changing visible order.""" + surface = WHITESPACE_RE.sub(" ", unicodedata.normalize("NFKC", raw_name)).strip() + if not surface or "," in surface: + return None + + native = self._infer_native(surface, japanese_probability) + if native is not None: + return native + return self._infer_romanized(surface) + + def _infer_native( + self, + surface: str, + japanese_probability: Callable[[str], float], + ) -> EastAsianNameOrderDecision | None: + if _is_hangul(surface): + if len(surface) != KOREAN_NATIVE_TOKEN_LENGTH: + return None + return EastAsianNameOrderDecision( + surface=surface, + given_tokens=(surface[1:],), + middle_tokens=(), + surname_tokens=(surface[:1],), + source_order=("surname", "given"), + reason="korean_native_three_syllable", + ) + if not _is_compact_japanese(surface) or japanese_probability(surface) < JAPANESE_ML_THRESHOLD: + return None + boundary = self._japanese_native_boundary(surface) + return EastAsianNameOrderDecision( + surface=surface, + given_tokens=(surface[boundary:],), + middle_tokens=(), + surname_tokens=(surface[:boundary],), + source_order=("surname", "given"), + reason="japanese_native_dictionary", + ) + + @staticmethod + def _japanese_native_boundary(surface: str) -> int: + transition = _han_to_kana_boundary(surface) + if transition is not None: + return transition + lexicons = _native_lexicons() + default_boundary = 1 if len(surface) == 2 else 2 # noqa: PLR2004 + candidates: list[tuple[int, int, int, int]] = [] + for boundary in range(1, len(surface)): + score = ( + 4 * _contains(lexicons.japanese_surnames, surface[:boundary]) + + 2 * _contains(lexicons.japanese_given_names, surface[boundary:]) + + (boundary == default_boundary) + ) + candidates.append((score, -abs(boundary - default_boundary), -boundary, boundary)) + return max(candidates)[-1] + + def _infer_romanized(self, surface: str) -> EastAsianNameOrderDecision | None: + tokens = surface.split() + if not MIN_ROMANIZED_TOKENS <= len(tokens) <= MAX_ROMANIZED_TOKENS: + return None + if not all(all(character.isalpha() or character in "-'" for character in token) for token in tokens): + return None + lexicons = _roman_lexicons() + + vietnamese = self._infer_vietnamese(tokens, surface, lexicons) + if vietnamese is not None: + return vietnamese + korean = self._infer_korean(tokens, lexicons) + if korean is not None: + return korean + return self._infer_japanese_romanized(tokens, lexicons) + + @staticmethod + def _infer_vietnamese( + tokens: list[str], + surface: str, + lexicons: _RomanLexicons, + ) -> EastAsianNameOrderDecision | None: + if surface.isascii() or not _contains(lexicons.vietnamese_surnames, _fold(tokens[0])): + return None + middle_tokens = tuple(tokens[1:-1]) + return EastAsianNameOrderDecision( + surface=surface, + given_tokens=(tokens[-1],), + middle_tokens=middle_tokens, + surname_tokens=(tokens[0],), + source_order=("surname", *("middle" for _ in middle_tokens), "given"), + reason="vietnamese_unicode_surname_first", + ) + + @staticmethod + def _infer_korean( + tokens: list[str], + lexicons: _RomanLexicons, + ) -> EastAsianNameOrderDecision | None: + if len(tokens) > MAX_KOREAN_ROMANIZED_TOKENS: + return None + if not _contains(lexicons.korean_surnames, _fold(tokens[0])): + return None + if _contains(lexicons.korean_surnames, _fold(tokens[-1])): + return None + known_given = { + _fold(value) + for value in ( + NAME_ORDER_ROUTING_KOREAN_GIVEN_SYLLABLES + | KOREAN_GIVEN_PATTERNS + | KOREAN_SPECIFIC_PATTERNS + | KOREAN_AMBIGUOUS_PATTERNS + ) + } + given_parts = [_fold(part) for token in tokens[1:] for part in token.split("-") if part] + has_hyphen = any("-" in token for token in tokens[1:]) + all_known = bool(given_parts) and all(part in known_given for part in given_parts) + if not (has_hyphen or (len(tokens) == MAX_KOREAN_ROMANIZED_TOKENS and all_known)): + return None + given_tokens = tuple(tokens[1:]) + return EastAsianNameOrderDecision( + surface=" ".join(tokens), + given_tokens=given_tokens, + middle_tokens=(), + surname_tokens=(tokens[0],), + source_order=("surname", *("given" for _ in given_tokens)), + reason="korean_romanized_strict", + ) + + @staticmethod + def _infer_japanese_romanized( + tokens: list[str], + lexicons: _RomanLexicons, + ) -> EastAsianNameOrderDecision | None: + if len(tokens) != 2: # noqa: PLR2004 + return None + first_keys = _japanese_roman_keys(tokens[0]) + last_keys = _japanese_roman_keys(tokens[1]) + surname_first = _contains_any(lexicons.japanese_surnames, first_keys) and _contains_any( + lexicons.japanese_given_names, + last_keys, + ) + reverse_plausible = _contains_any(lexicons.japanese_given_names, first_keys) and _contains_any( + lexicons.japanese_surnames, + last_keys, + ) + if not surname_first or reverse_plausible: + return None + return EastAsianNameOrderDecision( + surface=" ".join(tokens), + given_tokens=(tokens[1],), + middle_tokens=(), + surname_tokens=(tokens[0],), + source_order=("surname", "given"), + reason="japanese_romanized_directional_dictionary", + ) + + +__all__ = ["EastAsianNameOrderDecision", "EastAsianNameOrderService"] diff --git a/sinonym/services/ethnicity.py b/sinonym/services/ethnicity.py index 0036b2a..1c6aacd 100644 --- a/sinonym/services/ethnicity.py +++ b/sinonym/services/ethnicity.py @@ -12,12 +12,16 @@ from sinonym.chinese_names_data import ( COMPOUND_VARIANTS, + ETHNICITY_CHINESE_SURNAME_ROMANIZATION_ALIASES, JAPANESE_SURNAMES, KOREAN_AMBIGUOUS_PATTERNS, + KOREAN_DIRECTIONAL_FAMILY_FIRST_SURNAMES, + KOREAN_DIRECTIONAL_SINGLE_GIVEN_NAMES, KOREAN_GIVEN_PAIRS, KOREAN_GIVEN_PATTERNS, KOREAN_ONLY_SURNAMES, KOREAN_SPECIFIC_PATTERNS, + NAME_ORDER_ROUTING_KOREAN_SURNAMES, OVERLAPPING_KOREAN_SURNAMES, OVERLAPPING_VIETNAMESE_SURNAMES, VIETNAMESE_GIVEN_PATTERNS, @@ -32,11 +36,22 @@ MIN_COMPOUND_SURNAME_TOKEN_COUNT = 3 JAPANESE_CLASSIFIER_REJECTION = "japanese" JAPANESE_CLASSIFIER_RUNTIME_ERROR = "ML Japanese classifier failed" +MIN_DIRECTIONAL_KOREAN_TOKENS = 2 +MAX_DIRECTIONAL_KOREAN_TOKENS = 3 +MIN_CONTEXTUAL_TAIWAN_SURNAME_FREQUENCY = 100.0 +CONTEXTUAL_TAIWAN_GIVEN_PARTS = { + "jungting": ("jung", "ting"), + "tsung-jr": ("tsung", "jr"), +} +KOREAN_DIRECTIONAL_SURNAMES = frozenset( + NAME_ORDER_ROUTING_KOREAN_SURNAMES | KOREAN_ONLY_SURNAMES | OVERLAPPING_KOREAN_SURNAMES, +) # Optional ML Japanese classifier imports - consolidated from separate service try: # Ensure custom model components are importable when deserializing import sinonym.ml_model_components # noqa: F401 + ML_AVAILABLE = True except ImportError: ML_AVAILABLE = False @@ -63,8 +78,7 @@ def __init__(self, confidence_threshold: float = 0.8): self._model = load_skops("chinese_japanese_classifier.skops") except Exception as skops_err: # noqa: BLE001 - skops may raise several deserialization errors. LOGGER.info( - "SKOPS model not available or failed to load (%s); " - "falling back to legacy joblib artifact.", + "SKOPS model not available or failed to load (%s); falling back to legacy joblib artifact.", skops_err, ) self._model = load_joblib("chinese_japanese_classifier.joblib") @@ -195,7 +209,6 @@ def classify_ethnicity( # Check if this is an all-Chinese character input that could be Japanese compact_chinese_text = self._normalizer._text_preprocessor.compact_all_chinese_input(original_text) if compact_chinese_text and self._ml_classifier.is_available(): - # Use ML classifier to check for Japanese names in Chinese characters ml_result = self._ml_classifier.classify_all_chinese_name(compact_chinese_text) @@ -224,10 +237,22 @@ def get_normalized(token: str) -> str: original_keys_normalized = [get_normalized(t) for t in expanded_tokens] expanded_keys = list(set(original_keys_raw + original_keys_normalized)) + # Directional Korean structure must be evaluated before every + # affirmative Chinese shortcut. Surname/given roles are essential: + # many individual syllables and surnames overlap with Chinese. + if self._has_directional_korean_structure(tokens): + return ParseResult.failure("Korean structural patterns detected") + + if self.contextual_taiwan_given_parts(tokens) is not None: + return ParseResult.success_with_name("") + # ================================================================= # TIER 1: DEFINITIVE EVIDENCE (High Confidence) # ================================================================= + if self._has_wade_giles_apostrophe_surname(tokens): + return ParseResult.success_with_name("") + # Single loop for all definitive evidence checks (short-circuit optimization) for key in expanded_keys: # Check for Western names first (most common case, faster lookup) @@ -249,6 +274,11 @@ def get_normalized(token: str) -> str: if clean_key in VIETNAMESE_ONLY_SURNAMES: return ParseResult.failure("appears to be Vietnamese name") + if self._has_reviewed_chinese_surname_alias(tokens): + return ParseResult.success_with_name("") + if self._has_dominant_surname_compact_initial(tokens): + return ParseResult.success_with_name("") + # ================================================================= # TIER 2: CULTURAL CONTEXT (Medium Confidence) # ================================================================= @@ -256,11 +286,8 @@ def get_normalized(token: str) -> str: # Optimized validation chain: calculate overlapping surname evidence once with reduced string ops def check_overlapping_surname(token): clean_token_lower = StringManipulationUtils.remove_spaces(token).lower() - return ( - clean_token_lower in self._data.surnames and ( - clean_token_lower in OVERLAPPING_KOREAN_SURNAMES or - clean_token_lower in OVERLAPPING_VIETNAMESE_SURNAMES - ) + return clean_token_lower in self._data.surnames and ( + clean_token_lower in OVERLAPPING_KOREAN_SURNAMES or clean_token_lower in OVERLAPPING_VIETNAMESE_SURNAMES ) has_overlapping_chinese_surname = any(check_overlapping_surname(token) for token in tokens) @@ -287,6 +314,78 @@ def check_overlapping_surname(token): # No Chinese evidence found return ParseResult.failure("no Chinese evidence found") + @staticmethod + def _has_reviewed_chinese_surname_alias(tokens: tuple[str, ...]) -> bool: + """Return whether a name edge is a reviewed Chinese surname spelling.""" + return bool(tokens) and ( + tokens[0].lower() in ETHNICITY_CHINESE_SURNAME_ROMANIZATION_ALIASES + or tokens[-1].lower() in ETHNICITY_CHINESE_SURNAME_ROMANIZATION_ALIASES + ) + + def _has_wade_giles_apostrophe_surname(self, tokens: tuple[str, ...]) -> bool: + """Return whether an exact name edge has backed Wade-Giles surname evidence.""" + return bool(tokens) and ( + self._surname_resolver.evidence_is_wade_giles_apostrophe_surname(tokens[0]) + or self._surname_resolver.evidence_is_wade_giles_apostrophe_surname(tokens[-1]) + ) + + def _has_dominant_surname_compact_initial(self, tokens: tuple[str, ...]) -> bool: + """Return dominant family-first surname plus a vowelless initial bundle.""" + if len(tokens) != 2: + return False + abbreviation = tokens[1] + return bool( + self._surname_resolver.evidence_is_dominant_surname(tokens[0]) + and abbreviation.isalpha() + and 2 <= len(abbreviation) <= 3 + and not any(character.lower() in "aeiou" for character in abbreviation), + ) + + @staticmethod + def _split_roman_components(tokens: tuple[str, ...]) -> list[str]: + """Return lowercase Roman components from spaced or hyphenated tokens.""" + return [part.lower() for token in tokens for part in token.split("-") if part and part.isalpha()] + + @classmethod + def _has_directional_korean_structure(cls, tokens: tuple[str, ...]) -> bool: + """Return whether surname and given evidence align as a Korean name.""" + if not MIN_DIRECTIONAL_KOREAN_TOKENS <= len(tokens) <= MAX_DIRECTIONAL_KOREAN_TOKENS: + return False + + lowered = tuple(StringManipulationUtils.remove_spaces(token).lower() for token in tokens) + given_first_parts = cls._split_roman_components(lowered[:-1]) + family_first_parts = cls._split_roman_components(lowered[1:]) + + given_first = lowered[-1] in KOREAN_DIRECTIONAL_SURNAMES and tuple(given_first_parts) in KOREAN_GIVEN_PAIRS + if given_first: + return True + + directional_single = ( + lowered[0] in KOREAN_DIRECTIONAL_FAMILY_FIRST_SURNAMES + and len(family_first_parts) == 1 + and family_first_parts[0] in KOREAN_DIRECTIONAL_SINGLE_GIVEN_NAMES + ) + if directional_single: + return True + + return bool( + lowered[0] in KOREAN_DIRECTIONAL_SURNAMES + and lowered[-1] not in KOREAN_DIRECTIONAL_SURNAMES + and tuple(family_first_parts) in KOREAN_GIVEN_PAIRS, + ) + + def contextual_taiwan_given_parts(self, tokens: tuple[str, ...]) -> tuple[str, str] | None: + """Return an exact contextual Taiwan given-name segmentation.""" + if len(tokens) != 2 or self._has_directional_korean_structure(tokens): + return None + given_key = StringManipulationUtils.remove_spaces(tokens[0]).lower() + parts = CONTEXTUAL_TAIWAN_GIVEN_PARTS.get(given_key) + if parts is None: + return None + if self._surname_resolver.evidence_frequency(tokens[-1]) < MIN_CONTEXTUAL_TAIWAN_SURNAME_FREQUENCY: + return None + return parts + def _classify_first_token_surname(self, tokens: tuple[str, ...]) -> str: """Classify the first token's surname type for ethnicity detection.""" if not tokens: @@ -367,6 +466,7 @@ def _korean_given_pairs(self, tokens: tuple[str, ...], surname_type: str) -> lis @staticmethod def _candidate_korean_given_sequences(tokens: tuple[str, ...], surname_type: str) -> list[list[str]]: """Return plausible given-token spans for Korean pair detection.""" + def split_given_tokens(given_tokens: tuple[str, ...]) -> list[str]: split_tokens: list[str] = [] for token in given_tokens: @@ -387,8 +487,7 @@ def split_given_tokens(given_tokens: tuple[str, ...]) -> list[str]: def _has_trailing_overlapping_korean_surname(tokens: tuple[str, ...]) -> bool: """Return whether a final token can be a Korean surname.""" return bool( - tokens - and StringManipulationUtils.remove_spaces(tokens[-1]).lower() in OVERLAPPING_KOREAN_SURNAMES, + tokens and StringManipulationUtils.remove_spaces(tokens[-1]).lower() in OVERLAPPING_KOREAN_SURNAMES, ) def _first_token_has_dominant_chinese_surname(self, tokens: tuple[str, ...]) -> bool: @@ -409,12 +508,17 @@ def _calculate_non_chinese_patterns_unified( # Calculate Korean score korean_score = self._calculate_korean_score_from_analysis( - analysis, tokens, expanded_keys, normalized_cache, + analysis, + tokens, + expanded_keys, + normalized_cache, ) # Calculate Vietnamese score vietnamese_score = self._calculate_vietnamese_score_from_analysis( - analysis, tokens, expanded_keys, + analysis, + tokens, + expanded_keys, ) return korean_score, vietnamese_score @@ -438,17 +542,12 @@ def _calculate_korean_score_from_analysis( score = 0.0 # Overlapping Korean surname anywhere in the name - overlapping_any = any( - StringManipulationUtils.remove_spaces(t).lower() in OVERLAPPING_KOREAN_SURNAMES - for t in tokens - ) + overlapping_any = any(StringManipulationUtils.remove_spaces(t).lower() in OVERLAPPING_KOREAN_SURNAMES for t in tokens) # Helper functions (reused from original) def is_chinese_given_strict(tok: str) -> bool: normalized = normalized_cache[tok] if normalized_cache and tok in normalized_cache else self._normalizer.norm(tok) - return ( - normalized in self._data.given_names_normalized or tok.lower() in self._data.given_names_normalized - ) + return normalized in self._data.given_names_normalized or tok.lower() in self._data.given_names_normalized def has_korean_signature(tok: str) -> bool: t = tok.lower() @@ -464,10 +563,7 @@ def has_korean_signature(tok: str) -> bool: first_cn = is_chinese_given_strict(first) second_cn = is_chinese_given_strict(second) if overlapping_any: - if ( - (has_korean_signature(first) or has_korean_signature(second)) - and not (first_cn and second_cn) - ): + if (has_korean_signature(first) or has_korean_signature(second)) and not (first_cn and second_cn): score += 3.0 elif (not (first_cn and second_cn)) and (has_korean_signature(first) or has_korean_signature(second)): score += 3.0 @@ -509,9 +605,7 @@ def _calculate_vietnamese_score_from_analysis( if vietnamese_surname_count >= 1 and vietnamese_given_count >= 1: # Check if any token is a Chinese surname - has_chinese_surname = any( - StringManipulationUtils.remove_spaces(key) in self._data.surnames for key in expanded_keys - ) + has_chinese_surname = any(StringManipulationUtils.remove_spaces(key) in self._data.surnames for key in expanded_keys) if not has_chinese_surname: score += 2.0 # Strong Vietnamese pattern diff --git a/sinonym/services/formatting.py b/sinonym/services/formatting.py index 25ca821..eb18b4b 100644 --- a/sinonym/services/formatting.py +++ b/sinonym/services/formatting.py @@ -17,6 +17,8 @@ from typing import TYPE_CHECKING +from sinonym.chinese_names_data import ETHNICITY_CHINESE_SURNAME_ROMANIZATION_ALIASES +from sinonym.services.name_lookup import DOMINANT_CHINESE_SURNAME_FREQ_MIN, SurnameResolver from sinonym.utils.string_manipulation import StringManipulationUtils if TYPE_CHECKING: @@ -38,6 +40,7 @@ def __init__(self, context_or_config, normalizer=None, data=None): self._config = context_or_config self._normalizer = normalizer self._data = data + self._surname_resolver = SurnameResolver(self._data, self._normalizer) def format_name_output( self, @@ -58,7 +61,15 @@ def format_name_output( - NO structural/pattern-based splitting (that belongs in TextPreprocessor) """ # First validate that given tokens could plausibly be Chinese - if not self._normalizer.validate_given_tokens(given_tokens, normalized_cache): + compact_initial = self._accepts_compact_initial(surname_tokens, given_tokens) + alias_given_parts = self._reviewed_alias_compact_given_parts(surname_tokens, given_tokens) + wade_giles_single_given = self._accepts_wade_giles_single_given(surname_tokens, given_tokens) + if ( + not self._normalizer.validate_given_tokens(given_tokens, normalized_cache) + and not compact_initial + and not alias_given_parts + and not wade_giles_single_given + ): msg = "given name tokens are not plausibly Chinese" raise ValueError(msg) @@ -75,6 +86,18 @@ def format_name_output( parts.append(token) continue + if compact_initial and len(given_tokens) == 1 and token == given_tokens[0]: + parts.append(token) + continue + + if alias_given_parts and len(given_tokens) == 1 and token == given_tokens[0]: + parts.extend(alias_given_parts) + continue + + if wade_giles_single_given and len(given_tokens) == 1 and token == given_tokens[0]: + parts.append(token) + continue + # Check if token is already a valid Chinese syllable (no splitting needed) if self._normalizer.is_valid_chinese_phonetics(token): parts.append(token) @@ -128,9 +151,7 @@ def format_name_output( # Determine separator based on part lengths # Use spaces when we have mixed-length parts (some single chars, some multi-char) if len(formatted_parts) > 1: - part_lengths = [ - len(part.replace("-", "")) for part in formatted_parts - ] # Count chars, ignoring internal hyphens + part_lengths = [len(part.replace("-", "")) for part in formatted_parts] # Count chars, ignoring internal hyphens has_single_char = any(length == 1 for length in part_lengths) has_multi_char = any(length > 1 for length in part_lengths) @@ -208,7 +229,15 @@ def format_name_output_with_tokens( - surname_str / given_str: component strings as used in full_formatted_name """ # Validate given name tokens first - if not self._normalizer.validate_given_tokens(given_tokens, normalized_cache): + compact_initial = self._accepts_compact_initial(surname_tokens, given_tokens) + alias_given_parts = self._reviewed_alias_compact_given_parts(surname_tokens, given_tokens) + wade_giles_single_given = self._accepts_wade_giles_single_given(surname_tokens, given_tokens) + if ( + not self._normalizer.validate_given_tokens(given_tokens, normalized_cache) + and not compact_initial + and not alias_given_parts + and not wade_giles_single_given + ): msg = "given name tokens are not plausibly Chinese" raise ValueError(msg) @@ -224,6 +253,18 @@ def format_name_output_with_tokens( parts.append(token) continue + if compact_initial and len(given_tokens) == 1 and token == given_tokens[0]: + parts.append(token) + continue + + if alias_given_parts and len(given_tokens) == 1 and token == given_tokens[0]: + parts.extend(alias_given_parts) + continue + + if wade_giles_single_given and len(given_tokens) == 1 and token == given_tokens[0]: + parts.append(token) + continue + if self._normalizer.is_valid_chinese_phonetics(token): parts.append(token) continue @@ -356,6 +397,53 @@ def format_name_output_with_tokens( return full_formatted, given_tokens_final, surname_tokens_final, surname_str, given_str, middle_tokens_final + def _accepts_compact_initial(self, surname_tokens: list[str], given_tokens: list[str]) -> bool: + """Return dominant surname plus a single vowelless 2-3 letter bundle.""" + if len(surname_tokens) != 1 or len(given_tokens) != 1: + return False + abbreviation = given_tokens[0] + surname_key = self._normalizer.norm_light(surname_tokens[0]) + return bool( + self._data.get_surname_freq_as_written(surname_key) >= DOMINANT_CHINESE_SURNAME_FREQ_MIN + and abbreviation.isalpha() + and 2 <= len(abbreviation) <= 3 # noqa: PLR2004 + and not any(character.lower() in "aeiou" for character in abbreviation), + ) + + def _accepts_wade_giles_single_given( + self, + surname_tokens: list[str], + given_tokens: list[str], + ) -> bool: + """Allow one alphabetic given token behind exact Wade-Giles surname evidence.""" + return bool( + len(surname_tokens) == 1 + and len(given_tokens) == 1 + and given_tokens[0].isalpha() + and self._surname_resolver.evidence_is_wade_giles_apostrophe_surname(surname_tokens[0]), + ) + + def _reviewed_alias_compact_given_parts( + self, + surname_tokens: list[str], + given_tokens: list[str], + ) -> list[str]: + """Split one compact given token only under reviewed surname-alias evidence.""" + if ( + len(surname_tokens) != 1 + or len(given_tokens) != 1 + or surname_tokens[0].lower() not in ETHNICITY_CHINESE_SURNAME_ROMANIZATION_ALIASES + ): + return [] + token = given_tokens[0] + candidates = [ + [token[:index], token[index:]] + for index in range(1, len(token)) + if self._normalizer.is_valid_chinese_phonetics(token[:index]) + and self._normalizer.is_valid_chinese_phonetics(token[index:]) + ] + return candidates[0] if len(candidates) == 1 else [] + def capitalize_name_part(self, part: str) -> str: """Properly capitalize a name part - delegated to centralized utility.""" return StringManipulationUtils.capitalize_name_part(part) @@ -374,6 +462,7 @@ def _format_compound_with_metadata( Returns: Formatted surname string using metadata-driven formatting """ + def _get_meta_for_token(token: str) -> CompoundMetadata | None: meta = compound_metadata.get(token) if meta is not None: diff --git a/sinonym/services/name_lookup.py b/sinonym/services/name_lookup.py index b581205..1df391b 100644 --- a/sinonym/services/name_lookup.py +++ b/sinonym/services/name_lookup.py @@ -2,11 +2,26 @@ from __future__ import annotations +import re from typing import TYPE_CHECKING from sinonym.utils.string_manipulation import StringManipulationUtils DOMINANT_CHINESE_SURNAME_FREQ_MIN = 10_000.0 +_WADE_GILES_APOSTROPHE_SURNAME_RE = re.compile(r"(?:ch|ts|tz|k|p|t)'[a-z]+", re.IGNORECASE) +_APOSTROPHE_TRANSLATION = str.maketrans( + { + "\u02b9": "'", + "\u02bb": "'", + "\u02bc": "'", + "\u2018": "'", + "\u2019": "'", + "\u2032": "'", + "\uff07": "'", + "`": "'", + "\u00b4": "'", + }, +) if TYPE_CHECKING: from collections.abc import Sequence @@ -63,6 +78,13 @@ def evidence_is_dominant_surname(self, token: str) -> bool: """Return whether ``token`` carries dominant as-written Chinese surname evidence.""" return self.evidence_frequency(token) >= DOMINANT_CHINESE_SURNAME_FREQ_MIN + def evidence_is_wade_giles_apostrophe_surname(self, token: str) -> bool: + """Return exact-token Wade-Giles spelling backed by existing surname data.""" + normalized_apostrophe = token.translate(_APOSTROPHE_TRANSLATION) + return bool( + _WADE_GILES_APOSTROPHE_SURNAME_RE.fullmatch(normalized_apostrophe) and self.evidence_is_surname(token), + ) + def evidence_span_key(self, token: str) -> str: """Return the evidence key for batch span assembly only.""" return self._evidence_key(token) diff --git a/sinonym/services/normalization.py b/sinonym/services/normalization.py index d484d3c..8ebf2ad 100644 --- a/sinonym/services/normalization.py +++ b/sinonym/services/normalization.py @@ -323,8 +323,8 @@ def aligned_bilingual_pairs(self, normalized_input: NormalizedInput) -> tuple[Bi else: return None - han_pinyin = tuple(self._cache_service.han_to_pinyin_fast(han_token)) - if not roman_token or not han_pinyin or not self._roman_matches_han_token(roman_token, han_pinyin): + han_pinyin = self._matching_aligned_han_pinyin(roman_token, han_token) + if not roman_token or han_pinyin is None: return None pairs.append(BilingualTokenPair(roman_token=roman_token, han_token=han_token, han_pinyin=han_pinyin)) @@ -332,6 +332,22 @@ def aligned_bilingual_pairs(self, normalized_input: NormalizedInput) -> tuple[Bi return tuple(pairs) if len(pairs) >= MIN_BILINGUAL_ALIGNMENT_PAIRS else None + def _matching_aligned_han_pinyin(self, roman_token: str, han_token: str) -> tuple[str, ...] | None: + """Match one explicit pair, allowing heteronyms only for recognized Han surnames.""" + primary = tuple(self._cache_service.han_to_pinyin_fast(han_token)) + if primary and self._roman_matches_han_token(roman_token, primary): + return primary + if self._data is None or self._data.surname_frequencies.get(han_token, 0.0) <= 0: + return None + return next( + ( + alternative + for alternative in self._cache_service.han_to_pinyin_alternatives_fast(han_token) + if self._roman_matches_han_token(roman_token, alternative) + ), + None, + ) + def _is_han_token(self, token: str) -> bool: """Return whether the whole token is CJK characters.""" return bool(token) and all(self._config.cjk_pattern.search(char) for char in token) diff --git a/sinonym/services/parsing.py b/sinonym/services/parsing.py index 1047fd6..0ecf94d 100644 --- a/sinonym/services/parsing.py +++ b/sinonym/services/parsing.py @@ -468,7 +468,7 @@ def _generate_all_parses_with_format( ): # This is a multi-token compound in the middle original_format = self._get_compound_original_format(second_meta, tokens[1:3]) - parses.append((tokens[1:3], tokens[0:1], original_format)) + parses.append((tokens[1:3], [tokens[0], *tokens[3:]], original_format)) # 2. Single-token surnames - only at beginning or end (contiguous sequences only) # Surname-first pattern: surname + given_names diff --git a/sinonym/services/person_name_normalization.py b/sinonym/services/person_name_normalization.py new file mode 100644 index 0000000..ee7be73 --- /dev/null +++ b/sinonym/services/person_name_normalization.py @@ -0,0 +1,1415 @@ +"""Dependency-free canonical normalization for personal names. + +This module deliberately does not decide whether a name is Chinese and does not +call the Chinese parser. It provides a small boundary-normalization contract +that detector and batch entry points can invoke after their existing routing +decisions have been made. +""" + +from __future__ import annotations + +import re +import unicodedata +from dataclasses import dataclass, replace +from enum import Enum + +from sinonym.coretypes import CanonicalName, NameComponents + + +class PersonNameOutcome(str, Enum): + """Typed classification returned by canonical name normalization.""" + + PERSON = "person" + NON_PERSON = "non_person" + INVALID = "invalid" + + +class DropReason(str, Enum): + """Reason that a source token was intentionally omitted.""" + + TITLE = "title" + CREDENTIAL = "credential" + AFFILIATION = "affiliation" + CONNECTOR = "connector" + DUPLICATE = "duplicate" + + +@dataclass(frozen=True) +class DroppedNameToken: + """One source token omitted from the canonical name.""" + + text: str + source_role: str + reason: DropReason + + +@dataclass(frozen=True) +class PersonNameNormalizationResult: + """Typed outcome of one canonical name normalization request.""" + + outcome: PersonNameOutcome + canonical_name: CanonicalName | None = None + reason: str | None = None + dropped_tokens: tuple[DroppedNameToken, ...] = () + + +@dataclass(frozen=True) +class _Token: + text: str + source_role: str + position: int + source_text: str | None = None + + +@dataclass(frozen=True) +class _DroppedToken: + token: _Token + reason: DropReason + + +_WHITESPACE_RE = re.compile(r"\s+") +_JOINER_SPACING_RE = re.compile(r"\s*([-'])\s*") +_DUPLICATE_APOSTROPHE_RE = re.compile(r"'{2,}") +_LEADING_STRAY_JOINER_RE = re.compile(r"^[-']\s+") +_WORD_AND_RE = re.compile(r"\band\b", re.IGNORECASE) +_TOKEN_RE = re.compile(r"\S+") +_TRAILING_DIGITS_RE = re.compile(r"\d+$") +_INITIAL_RE = re.compile(r"([^\W\d_])\.", re.UNICODE) +_MULTI_INITIAL_RE = re.compile(r"(?:[^\W\d_]\.)+[^\W\d_]\.?", re.UNICODE) +_ABBREVIATED_TOKEN_RE = re.compile(r"(?:[^\W\d_]+[-'])*[^\W\d_]{1,3}\.", re.UNICODE) +_COMPOUND_INITIAL_RE = re.compile(r"[^\W\d_]\.-[^\W\d_]\.", re.UNICODE) +_LEADING_HYPHEN_INITIAL_RE = re.compile(r"^-([^\W\d_])\.$", re.UNICODE) +_FUSED_INITIAL_SURNAME_RE = re.compile(r"^([^\W\d_])\.(.+)$", re.UNICODE) +_LOWER_MARKER_INITIAL_RE = re.compile(r"^([a-z])([A-Z])\.$") +_PARENTHETICAL_DUPLICATE_RE = re.compile(r"^(\S+)\s+\(([^)]+)\)\s+(.+)$") +_ASCII_JOINER_TRANSLATION = str.maketrans({"`": "'"}) +_PRE_NFKC_JOINER_TRANSLATION = str.maketrans({"\u00b4": "'"}) + +_APOSTROPHE_LIKE = frozenset( + { + "'", + "\u02b9", # modifier letter prime + "\u02bb", # modifier letter turned comma + "\u02bc", # modifier letter apostrophe + "\u2018", # left single quotation mark + "\u2019", # right single quotation mark + "\u201a", # single low-9 quotation mark + "\u201b", # single high-reversed-9 quotation mark + "\u2032", # prime + "\u2035", # reversed prime + "\ua78b", # Latin capital letter saltillo + "\ua78c", # Latin small letter saltillo + "\uff07", # fullwidth apostrophe + "`", # grave accent used as an apostrophe + "\uff40", # fullwidth grave accent + "\u00b4", # acute accent used as an apostrophe + }, +) +_DASH_LIKE_EXTRAS = frozenset({"\u2043", "\u2212", "\u208b"}) + +_TITLE_KEYS = frozenset( + { + "capt", + "captain", + "chaplain", + "dame", + "doctor", + "dr", + "father", + "frau", + "hon", + "honorable", + "lord", + "miss", + "mr", + "mrs", + "ms", + "pastor", + "prof", + "professor", + "rabbi", + "rev", + "reverend", + "sir", + "univprof", + }, +) +_TITLE_QUALIFIER_KEYS = frozenset({"hc", "habil", "honoraire", "med", "nat", "rer"}) +_LEADING_NAME_ABBREVIATION_KEYS = frozenset({"md"}) +_LONG_NAME_ABBREVIATION_KEYS = frozenset({"mohd", "most"}) +_CREDENTIAL_KEYS = frozenset( + { + "ba", + "bs", + "bsc", + "dds", + "dmd", + "do", + "dphil", + "dvm", + "edd", + "esq", + "facp", + "facr", + "frcp", + "jd", + "llb", + "llm", + "ma", + "mba", + "md", + "meng", + "mph", + "mpa", + "ms", + "msc", + "pharmd", + "phd", + "psyd", + "rn", + }, +) +_AMBIGUOUS_CREDENTIAL_KEYS = frozenset({"ba", "bs", "do", "jd", "ma", "mba", "md", "meng", "mpa", "ms", "rn"}) +_MIXED_CASE_CREDENTIALS = {"meng": "MEng"} +_FAMILY_PARTICLES = frozenset( + { + "al", + "ap", + "ben", + "bin", + "da", + "das", + "dal", + "de", + "del", + "della", + "den", + "der", + "di", + "dos", + "du", + "el", + "ibn", + "la", + "las", + "le", + "los", + "st", + "ter", + "van", + "von", + "zu", + "zum", + "zur", + }, +) +_LOWERCASE_RELATIONAL_TOKENS = frozenset({"b.", "d.", "e", "kizi", "kyzy", "oglu", "o'g'li", "oğlu", "qizi"}) +_STRONG_FAMILY_PARTICLE_SPANS = ( + ("de", "la"), + ("de", "las"), + ("de", "los"), + ("van", "der"), + ("von", "der"), + ("da",), + ("das",), + ("dos",), +) +_ORGANIZATION_WORDS = frozenset( + { + "association", + "center", + "centre", + "committee", + "company", + "consortium", + "corporation", + "department", + "hospital", + "inc", + "institute", + "laboratory", + "ltd", + "society", + "team", + "university", + }, +) +_STANDARD_SUFFIXES = { + "jr": "Jr.", + "junior": "Jr.", + "sr": "Sr.", + "senior": "Sr.", + "2nd": "2nd", + "3rd": "3rd", + "4th": "4th", + "5th": "5th", + "6th": "6th", +} +_RAW_ROMAN_SUFFIXES = frozenset({"II", "III", "IV", "VI", "VII", "VIII", "IX", "X"}) +_EXPLICIT_ROMAN_SUFFIXES = _RAW_ROMAN_SUFFIXES | {"I", "V", "X"} +_CASE_INSENSITIVE_ROMAN_SUFFIXES = _RAW_ROMAN_SUFFIXES - {"II"} +_TWO_COMPONENTS = 2 +_THREE_COMPONENTS = 3 +_FOUR_COMPONENTS = 4 +_MAX_SPACED_CREDENTIAL_TOKENS = 3 +_MAX_DOTTED_ABBREVIATION_LETTERS = 3 +_MIN_PARENTHESIZED_TOKEN_LENGTH = 3 +_MC_PREFIX_LENGTH = 2 + + +class PersonNameNormalizationService: + """Normalize raw or structured personal names without Chinese routing.""" + + def normalize_text(self, raw_name: str | None) -> PersonNameNormalizationResult: # noqa: C901, PLR0911, PLR0912 + """Normalize one raw name string into semantic canonical components.""" + if not isinstance(raw_name, str): + return self._invalid("name must be a string") + + source_text = raw_name + normalized_input, leading_markers = self._strip_leading_superscript_affiliation(raw_name) + surface = self._normalize_surface(normalized_input) + if not surface: + return self._invalid("name is empty") + if not any(character.isalpha() for character in surface): + return self._invalid("name has no letters") + + dropped: list[_DroppedToken] = [] + if leading_markers: + dropped.append(_DroppedToken(_Token(leading_markers, "", 0), DropReason.AFFILIATION)) + surface, affiliation_dropped = self._strip_trailing_affiliation_surface(surface) + dropped.extend(affiliation_dropped) + surface = self._collapse_parenthetical_duplicate_surface(surface) + non_person_reason = self._non_person_reason(surface) + if non_person_reason is not None: + return self._non_person(non_person_reason) + + segment_matches = list(re.finditer(r"[^,]+", surface)) + segments = [self._tokens(match.group(), "", match.start()) for match in segment_matches] + segments = [segment for segment in segments if segment] + if not segments: + return self._invalid("name has no usable tokens") + if leading_markers: + first = segments[0][0] + segments[0][0] = replace(first, source_text=f"{leading_markers}{first.text}") + + suffix = "" + suffix_token: _Token | None = None + while len(segments) > 1: + ambiguous_given_segment = ( + len(segments) == _TWO_COMPONENTS + and len(segments[-1]) == 1 + and self._is_ambiguous_credential(segments[-1][0].text) + and ( + len(segments[0]) == 1 + or any(self._particle_key(token.text) in _FAMILY_PARTICLES for token in segments[0][:-1]) + ) + ) + if ambiguous_given_segment: + break + boundary = self._consume_boundary_segment(segments[-1]) + if boundary is None: + break + segment_suffix, segment_suffix_token, segment_dropped = boundary + segments.pop() + dropped.extend(segment_dropped) + if segment_suffix: + if suffix: + return self._invalid("name has multiple suffixes", dropped) + suffix = segment_suffix + suffix_token = segment_suffix_token + + if len(segments) > _TWO_COMPONENTS: + return self._non_person("multiple comma-separated names") + if len(segments) == _TWO_COMPONENTS: + return self._normalize_comma_name( + source_text, + segments[0], + segments[1], + suffix, + suffix_token, + dropped, + ) + return self._normalize_regular_name(source_text, segments[0], suffix, suffix_token, dropped) + + def normalize_components( # noqa: C901, PLR0911 + self, + *, + first_name: str | None = None, + middle_name: str | None = None, + last_name: str | None = None, + suffix: str | None = None, + ) -> PersonNameNormalizationResult: + """Normalize already structured first, middle, last, and suffix fields.""" + values = { + "given": first_name, + "middle": middle_name, + "surname": last_name, + "suffix": suffix, + } + if any(value is not None and not isinstance(value, str) for value in values.values()): + return self._invalid("name components must be strings or null") + + stripped_values: dict[str, str] = {} + leading_markers_by_role: dict[str, str] = {} + for role, value in values.items(): + stripped_values[role], leading_markers_by_role[role] = self._strip_leading_superscript_affiliation(value or "") + surfaces = {role: self._normalize_surface(value) for role, value in stripped_values.items()} + source_surfaces = { + role: f"{leading_markers_by_role[role]}{surface}" if surface else "" for role, surface in surfaces.items() + } + source_text = " ".join(surface for surface in source_surfaces.values() if surface) + if not source_text: + return self._invalid("name is empty") + if not any(character.isalpha() for character in source_text): + return self._invalid("name has no letters") + + non_person_reason = self._non_person_reason(source_text) + if non_person_reason is not None: + return self._non_person(non_person_reason) + + position = 0 + source_by_role: dict[str, list[_Token]] = {} + for role in ("given", "middle", "surname", "suffix"): + source_by_role[role] = self._tokens(surfaces[role], role, position) + if leading_markers_by_role[role] and source_by_role[role]: + first = source_by_role[role][0] + source_by_role[role][0] = replace( + first, + source_text=f"{leading_markers_by_role[role]}{first.text}", + ) + position += len(surfaces[role]) + 1 + source = self._components( + source_by_role["given"], + source_by_role["middle"], + source_by_role["surname"], + source_by_role["suffix"], + order=self._structured_source_order(source_by_role), + ) + + dropped = [ + _DroppedToken( + _Token(markers, role, source_by_role[role][0].position), + DropReason.AFFILIATION, + ) + for role, markers in leading_markers_by_role.items() + if markers and source_by_role[role] + ] + middle_tokens = self._strip_structured_middle_surname_artifact( + source_by_role["middle"], + source_by_role["surname"], + dropped, + ) + name_tokens = [ + *source_by_role["given"], + *middle_tokens, + *source_by_role["surname"], + ] + name_tokens = self._repair_leading_marker_initial(name_tokens, dropped) + name_tokens = self._join_separated_compound_initials(name_tokens) + name_tokens = self._strip_leading_titles(name_tokens, dropped) + name_tokens = self._strip_standalone_periods(name_tokens, dropped) + name_tokens = self._strip_boundary_markers(name_tokens, dropped) + + canonical_suffix, _, explicit_dropped = self._consume_explicit_suffix( + source_by_role["suffix"], + ) + dropped.extend(explicit_dropped) + name_tokens, boundary_suffix, _ = self._strip_trailing_boundaries(name_tokens, dropped) + if boundary_suffix: + if canonical_suffix: + return self._invalid("name has multiple suffixes", dropped) + canonical_suffix = boundary_suffix + + name_tokens = self._strip_attached_affiliations(name_tokens, dropped) + invalid_reason = self._invalid_token_reason(name_tokens) + if invalid_reason is not None: + return self._invalid(invalid_reason, dropped) + + cleaned_by_role = { + role: [token for token in name_tokens if token.source_role == role] for role in ("given", "middle", "surname") + } + given, middle, surname = self._repair_structured_roles(cleaned_by_role) + if not given and not middle and not surname: + return self._invalid("no personal-name tokens remain", dropped) + + return self._person(source_text, source, given, middle, surname, canonical_suffix, dropped) + + def _normalize_regular_name( + self, + source_text: str, + tokens: list[_Token], + suffix: str, + suffix_token: _Token | None, + dropped: list[_DroppedToken], + ) -> PersonNameNormalizationResult: + original_tokens = list(tokens) + tokens = self._repair_leading_marker_initial(tokens, dropped) + tokens = self._join_separated_compound_initials(tokens) + tokens = self._strip_leading_titles(tokens, dropped) + tokens = self._strip_standalone_periods(tokens, dropped) + tokens = self._strip_boundary_markers(tokens, dropped) + tokens, boundary_suffix, boundary_suffix_token = self._strip_trailing_boundaries(tokens, dropped) + if boundary_suffix: + if suffix: + return self._invalid("name has multiple suffixes", dropped) + suffix = boundary_suffix + suffix_token = boundary_suffix_token + + tokens = self._strip_attached_affiliations(tokens, dropped) + invalid_reason = self._invalid_token_reason(tokens) + if invalid_reason is not None: + return self._invalid(invalid_reason, dropped) + if not tokens: + return self._invalid("no personal-name tokens remain", dropped) + + packed_surname_first = self._is_packed_surname_first_initials(tokens) + given, middle, surname = self._infer_regular_roles(tokens) + assigned = [*given, *middle, *surname] + assigned_by_position = {token.position: token for token in assigned} + prefix_source = self._regular_prefix_source( + original_tokens, + assigned_by_position, + tokens[0], + dropped, + ) + credential_source = [ + replace(item.token, source_role="suffix") for item in dropped if item.reason is DropReason.CREDENTIAL + ] + source_suffix = [*([suffix_token] if suffix_token is not None else []), *credential_source] + source_order = None + if packed_surname_first and not prefix_source and not source_suffix: + source_order = ("surname", "given", *("middle" for _token in middle)) + source = self._components( + [*prefix_source, *given], + middle, + surname, + source_suffix, + order=source_order, + ) + return self._person(source_text, source, given, middle, surname, suffix, dropped) + + @staticmethod + def _regular_prefix_source( + original_tokens: list[_Token], + assigned_by_position: dict[int, _Token], + first_survivor: _Token, + dropped: list[_DroppedToken], + ) -> list[_Token]: + """Rebuild raw prefix lineage without duplicating an attached-title remainder.""" + prefix: list[_Token] = [] + for token in original_tokens: + if token.position >= first_survivor.position or token.position in assigned_by_position: + continue + if token.position + len(token.text) <= first_survivor.position: + prefix.append(replace(token, source_role="given")) + continue + prefix.extend( + replace(item.token, source_role="given") + for item in dropped + if item.reason is DropReason.TITLE and item.token.position == token.position + ) + return prefix + + def _normalize_comma_name( # noqa: PLR0913 + self, + source_text: str, + family_tokens: list[_Token], + given_tokens: list[_Token], + suffix: str, + suffix_token: _Token | None, + dropped: list[_DroppedToken], + ) -> PersonNameNormalizationResult: + family_tokens = [replace(token, source_role="surname") for token in family_tokens] + given_tokens = [replace(token, source_role="given") for token in given_tokens] + family_tokens = self._strip_leading_titles(family_tokens, dropped, preserve_ambiguous_credentials=True) + given_tokens = self._strip_leading_titles(given_tokens, dropped, preserve_ambiguous_credentials=True) + family_tokens = self._strip_standalone_periods(family_tokens, dropped) + given_tokens = self._strip_standalone_periods(given_tokens, dropped) + family_tokens = self._strip_boundary_markers(family_tokens, dropped) + given_tokens = self._strip_boundary_markers(given_tokens, dropped) + given_tokens, boundary_suffix, boundary_suffix_token = self._strip_trailing_boundaries( + given_tokens, + dropped, + has_external_name_context=bool(family_tokens), + ) + if boundary_suffix: + if suffix: + return self._invalid("name has multiple suffixes", dropped) + suffix = boundary_suffix + suffix_token = boundary_suffix_token + + family_tokens = self._strip_attached_affiliations(family_tokens, dropped) + given_tokens = self._strip_attached_affiliations(given_tokens, dropped) + invalid_reason = self._invalid_token_reason([*family_tokens, *given_tokens]) + if invalid_reason is not None: + return self._invalid(invalid_reason, dropped) + if not family_tokens or not given_tokens: + return self._invalid("comma form requires family and given tokens", dropped) + if self._looks_like_two_complete_names(family_tokens, given_tokens): + return self._non_person("comma separates two complete names") + + given = [replace(given_tokens[0], source_role="given")] + middle = [replace(token, source_role="middle") for token in given_tokens[1:]] + surname = [replace(token, source_role="surname") for token in family_tokens] + source_suffix = [suffix_token] if suffix_token is not None else [] + source = self._components( + given, + middle, + surname, + source_suffix, + order=tuple(["surname"] * len(surname) + ["given"] + ["middle"] * len(middle) + ["suffix"] * len(source_suffix)), + ) + return self._person(source_text, source, given, middle, surname, suffix, dropped) + + def _person( # noqa: PLR0913 + self, + source_text: str, + source: NameComponents, + given: list[_Token], + middle: list[_Token], + surname: list[_Token], + suffix: str, + dropped: list[_DroppedToken], + ) -> PersonNameNormalizationResult: + normalized_given = self._canonical_tokens(given, "given") + normalized_middle = self._canonical_tokens(middle, "middle") + normalized_surname = self._canonical_tokens(surname, "surname") + normalized_suffix = tuple([suffix] if suffix else []) + normalized = NameComponents( + given_name=" ".join(normalized_given), + middle_name=" ".join(normalized_middle), + surname=" ".join(normalized_surname), + suffix=suffix, + given_tokens=normalized_given, + middle_tokens=normalized_middle, + surname_tokens=normalized_surname, + suffix_tokens=normalized_suffix, + order=tuple( + ["given"] * len(normalized_given) + + ["middle"] * len(normalized_middle) + + ["surname"] * len(normalized_surname) + + ["suffix"] * len(normalized_suffix), + ), + ) + text = " ".join( + component + for component in (normalized.given_name, normalized.middle_name, normalized.surname, normalized.suffix) + if component + ) + if not text: + return self._invalid("no personal-name tokens remain", dropped) + + canonical_name = CanonicalName(source_text=source_text, text=text, source=source, normalized=normalized) + dropped = self._infer_dropped_roles(dropped, [*given, *middle, *surname]) + return PersonNameNormalizationResult( + outcome=PersonNameOutcome.PERSON, + canonical_name=canonical_name, + dropped_tokens=self._public_dropped(dropped), + ) + + @staticmethod + def _normalize_surface(value: str) -> str: + if value.isascii(): + normalized = value.translate(_ASCII_JOINER_TRANSLATION) + else: + normalized = value.translate(_PRE_NFKC_JOINER_TRANSLATION) + normalized = unicodedata.normalize("NFKC", normalized) + normalized = "".join(PersonNameNormalizationService._normalize_joiner(character) for character in normalized) + normalized = unicodedata.normalize("NFC", normalized) + normalized = _WHITESPACE_RE.sub(" ", normalized) + normalized = _LEADING_STRAY_JOINER_RE.sub("", normalized) + normalized = _JOINER_SPACING_RE.sub(r"\1", normalized) + normalized = _DUPLICATE_APOSTROPHE_RE.sub("'", normalized) + return normalized.strip(" \t\r\n,") + + @staticmethod + def _strip_leading_superscript_affiliation(value: str) -> tuple[str, str]: + """Strip fused leading superscript digits while returning their lineage.""" + marker_end = 0 + while marker_end < len(value): + character = value[marker_end] + if "SUPERSCRIPT" not in unicodedata.name(character, "") or not character.isdigit(): + break + marker_end += 1 + if marker_end == 0 or marker_end >= len(value) or not value[marker_end].isalpha(): + return value, "" + return value[marker_end:], value[:marker_end] + + def _strip_trailing_affiliation_surface( + self, + surface: str, + ) -> tuple[str, list[_DroppedToken]]: + """Remove an organization phrase after at least two name tokens.""" + tokens = self._tokens(surface, "suffix", 0) + for index, token in enumerate(tokens): + if index < _TWO_COMPONENTS or self._compact_key(token.text) not in _ORGANIZATION_WORDS: + continue + dropped = [_DroppedToken(item, DropReason.AFFILIATION) for item in tokens[index:]] + return surface[: token.position].rstrip(" ,"), dropped + return surface, [] + + def _collapse_parenthetical_duplicate_surface(self, surface: str) -> str: + """Collapse ``Alan (Alan B.) Cantor``-style duplicate given forms.""" + match = _PARENTHETICAL_DUPLICATE_RE.fullmatch(surface) + if match is None: + return surface + outer_given, parenthetical, remainder = match.groups() + parenthetical_tokens = self._tokens(parenthetical, "", 0) + if not parenthetical_tokens: + return surface + if self._compact_key(parenthetical_tokens[0].text) != self._compact_key(outer_given): + return surface + return f"{parenthetical} {remainder}" + + @staticmethod + def _normalize_joiner(character: str) -> str: + character_name = unicodedata.name(character, "") + is_single_quote = "SINGLE" in character_name and "QUOTATION MARK" in character_name + if character in _APOSTROPHE_LIKE or "APOSTROPHE" in character_name or is_single_quote: + return "'" + if ( + unicodedata.category(character) == "Pd" + or character in _DASH_LIKE_EXTRAS + or "HYPHEN" in character_name + or "MINUS SIGN" in character_name + ): + return "-" + return character + + @staticmethod + def _tokens(value: str, source_role: str, offset: int) -> list[_Token]: + return [_Token(match.group(), source_role, offset + match.start()) for match in _TOKEN_RE.finditer(value.strip())] + + @staticmethod + def _compact_key(token: str) -> str: + return "".join(character.casefold() for character in token if character.isalnum()) + + def _strip_leading_titles( + self, + tokens: list[_Token], + dropped: list[_DroppedToken], + *, + preserve_ambiguous_credentials: bool = False, + ) -> list[_Token]: + remaining = list(tokens) + if self._has_leading_et_al_contamination(remaining): + dropped.extend(_DroppedToken(token, DropReason.CONNECTOR) for token in remaining[:2]) + remaining = remaining[2:] + stripped_title = False + while remaining: + token = remaining[0] + attached_title = self._split_attached_leading_title(token) + if attached_title is not None: + title, remainder = attached_title + dropped.append(_DroppedToken(title, DropReason.TITLE)) + remaining[0] = remainder + stripped_title = True + continue + key = self._compact_key(token.text) + if token.text == "AND": + dropped.append(_DroppedToken(token, DropReason.CONNECTOR)) + remaining.pop(0) + continue + multi_initial = bool(_MULTI_INITIAL_RE.fullmatch(token.text)) + prefixed_academic_title = ( + token.text == "PD" and len(remaining) > 1 and self._compact_key(remaining[1].text) in _TITLE_KEYS + ) + if ( + (key in _TITLE_KEYS and not multi_initial) + or (stripped_title and key in _TITLE_QUALIFIER_KEYS) + or prefixed_academic_title + ): + dropped.append(_DroppedToken(token, DropReason.TITLE)) + remaining.pop(0) + stripped_title = True + continue + ambiguous_name_token = self._is_ambiguous_credential(token.text) and ( + preserve_ambiguous_credentials or len(remaining) == _TWO_COMPONENTS + ) + leading_name_abbreviation = ( + key in _LEADING_NAME_ABBREVIATION_KEYS and token.text.endswith(".") and len(remaining) >= _TWO_COMPONENTS + ) or self._is_exact_ma_given_abbreviation(remaining) + if self._is_credential(token.text) and not ambiguous_name_token and not leading_name_abbreviation: + dropped.append(_DroppedToken(token, DropReason.CREDENTIAL)) + remaining.pop(0) + continue + break + return remaining + + @staticmethod + def _has_leading_et_al_contamination(tokens: list[_Token]) -> bool: + """Match only a leading citation marker followed by a two-token name.""" + return bool( + len(tokens) >= _FOUR_COMPONENTS and tokens[0].text.casefold() == "et" and tokens[1].text.casefold() == "al.", + ) + + def _split_attached_leading_title(self, token: _Token) -> tuple[_Token, _Token] | None: + """Split ``Dr.Name`` only when the attached prefix is a known title.""" + prefix, separator, remainder = token.text.partition(".") + if not separator or not remainder or self._compact_key(prefix) not in _TITLE_KEYS: + return None + if not any(character.isalpha() for character in remainder): + return None + title = replace(token, text=f"{prefix}.") + name = replace(token, text=remainder, position=token.position + len(prefix) + 1) + return title, name + + def _strip_trailing_boundaries( + self, + tokens: list[_Token], + dropped: list[_DroppedToken], + *, + has_external_name_context: bool = False, + ) -> tuple[list[_Token], str, _Token | None]: + remaining = list(tokens) + suffix = "" + suffix_token: _Token | None = None + while len(remaining) >= _FOUR_COMPONENTS: + credential_width = self._trailing_spaced_credential_width(remaining) + if not credential_width: + break + dropped.extend(_DroppedToken(token, DropReason.CREDENTIAL) for token in remaining[-credential_width:]) + del remaining[-credential_width:] + while remaining: + token = remaining[-1] + ambiguous_name_token = self._is_ambiguous_credential(token.text) and ( + (not has_external_name_context and len(remaining) == _TWO_COMPONENTS) + or (has_external_name_context and len(remaining) == 1) + ) + if self._is_credential(token.text) and not ambiguous_name_token: + dropped.append(_DroppedToken(token, DropReason.CREDENTIAL)) + remaining.pop() + continue + if token.text.strip(".,").isdigit(): + dropped.append(_DroppedToken(token, DropReason.AFFILIATION)) + remaining.pop() + continue + candidate = self._canonical_suffix(token.text, explicit=False) + has_complete_name = len(remaining) > _TWO_COMPONENTS or has_external_name_context + is_roman = candidate in _RAW_ROMAN_SUFFIXES + if candidate and not suffix and (not is_roman or has_complete_name): + suffix = candidate + suffix_token = replace(token, source_role="suffix") + remaining.pop() + continue + break + return remaining, suffix, suffix_token + + def _consume_boundary_segment( + self, + tokens: list[_Token], + ) -> tuple[str, _Token | None, list[_DroppedToken]] | None: + suffix = "" + suffix_token: _Token | None = None + dropped: list[_DroppedToken] = [] + if self._is_spaced_credential_group(tokens): + dropped.extend(_DroppedToken(replace(token, source_role="suffix"), DropReason.CREDENTIAL) for token in tokens) + return suffix, suffix_token, dropped + for token in tokens: + if self._is_credential(token.text, explicit_context=True): + dropped.append(_DroppedToken(replace(token, source_role="suffix"), DropReason.CREDENTIAL)) + continue + if token.text.strip(".,").isdigit(): + dropped.append(_DroppedToken(replace(token, source_role="suffix"), DropReason.AFFILIATION)) + continue + candidate = self._canonical_suffix(token.text, explicit=False) + if candidate and not suffix: + suffix = candidate + suffix_token = replace(token, source_role="suffix") + continue + return None + return suffix, suffix_token, dropped + + def _consume_explicit_suffix( # noqa: PLR0911 + self, + tokens: list[_Token], + ) -> tuple[str, _Token | None, list[_DroppedToken]]: + if not tokens: + return "", None, [] + if len(tokens) > 1: + boundary = self._consume_boundary_segment(tokens) + if boundary is not None: + return boundary + return " ".join(self._canonicalize_token(token.text, "suffix") for token in tokens), tokens[0], [] + + token = tokens[0] + if self._is_credential(token.text, explicit_context=True): + return "", None, [_DroppedToken(token, DropReason.CREDENTIAL)] + if token.text.strip(".,").isdigit(): + return "", None, [_DroppedToken(token, DropReason.AFFILIATION)] + canonical = self._canonical_suffix(token.text, explicit=True) + if canonical: + return canonical, token, [] + return self._canonicalize_token(token.text, "suffix"), token, [] + + def _strip_attached_affiliations( + self, + tokens: list[_Token], + dropped: list[_DroppedToken], + ) -> list[_Token]: + cleaned: list[_Token] = [] + for token in tokens: + match = _TRAILING_DIGITS_RE.search(token.text) + if match is None or not any(character.isalpha() for character in token.text[: match.start()]): + cleaned.append(token) + continue + cleaned.append(replace(token, text=token.text[: match.start()])) + digit_token = _Token(match.group(), token.source_role, token.position + match.start()) + dropped.append(_DroppedToken(digit_token, DropReason.AFFILIATION)) + return cleaned + + @staticmethod + def _strip_standalone_periods( + tokens: list[_Token], + dropped: list[_DroppedToken], + ) -> list[_Token]: + """Drop standalone full stops while retaining their source lineage.""" + remaining = [] + for token in tokens: + if token.text == ".": + dropped.append(_DroppedToken(token, DropReason.CONNECTOR)) + else: + remaining.append(token) + return remaining + + @staticmethod + def _strip_boundary_markers( + tokens: list[_Token], + dropped: list[_DroppedToken], + ) -> list[_Token]: + """Drop boundary-only punctuation/symbol tokens such as author footnote daggers.""" + remaining = list(tokens) + while remaining and not any(character.isalnum() for character in remaining[0].text): + dropped.append(_DroppedToken(remaining.pop(0), DropReason.CONNECTOR)) + while remaining and not any(character.isalnum() for character in remaining[-1].text): + dropped.append(_DroppedToken(remaining.pop(), DropReason.CONNECTOR)) + return remaining + + @staticmethod + def _strip_structured_middle_surname_artifact( + middle: list[_Token], + surname: list[_Token], + dropped: list[_DroppedToken], + ) -> list[_Token]: + """Remove an exact duplicated surname plus one lowercase source marker.""" + marker_width = 1 + artifact_width = len(surname) + marker_width + if not surname or len(middle) <= artifact_width: + return list(middle) + marker = middle[-1].text + if len(marker) != marker_width or not marker.isalpha() or not marker.islower(): + return list(middle) + duplicate = middle[-artifact_width:-marker_width] + if tuple(token.text for token in duplicate) != tuple(token.text for token in surname): + return list(middle) + dropped.extend(_DroppedToken(token, DropReason.DUPLICATE) for token in duplicate) + dropped.append(_DroppedToken(middle[-1], DropReason.CONNECTOR)) + return list(middle[:-artifact_width]) + + def _repair_leading_marker_initial( + self, + tokens: list[_Token], + dropped: list[_DroppedToken], + ) -> list[_Token]: + """Split ``tE. L. Surname`` only at its fully constrained leading boundary.""" + if ( + len(tokens) != _THREE_COMPONENTS + or not self._is_initial(tokens[1].text) + or not self._is_full_name_token(tokens[2].text) + ): + return list(tokens) + match = _LOWER_MARKER_INITIAL_RE.fullmatch(tokens[0].text) + if match is None: + return list(tokens) + marker, initial = match.groups() + first = tokens[0] + marker_token = replace(first, text=marker, source_text=None) + dropped.append(_DroppedToken(marker_token, DropReason.CONNECTOR)) + repaired = replace( + first, + text=f"{initial}.", + position=first.position + len(marker), + source_text=first.source_text or first.text, + ) + return [repaired, *tokens[1:]] + + def _join_separated_compound_initials(self, tokens: list[_Token]) -> list[_Token]: + """Join ``H. -J. Surname`` only when both visible pieces are initials.""" + if len(tokens) < _THREE_COMPONENTS or not self._is_initial(tokens[0].text): + return list(tokens) + trailing_initial = _LEADING_HYPHEN_INITIAL_RE.fullmatch(tokens[1].text) + if trailing_initial is None or not self._is_full_name_token(tokens[-1].text): + return list(tokens) + combined = replace(tokens[0], text=f"{tokens[0].text}-{trailing_initial.group(1)}.") + return [combined, *tokens[2:]] + + def _is_exact_ma_given_abbreviation(self, tokens: list[_Token]) -> bool: + """Recognize exact mixed-case ``Ma. Initial Surname`` given-name context.""" + return bool( + len(tokens) == _THREE_COMPONENTS + and tokens[0].text == "Ma." + and self._is_initial(tokens[1].text) + and self._is_full_name_token(tokens[2].text), + ) + + def _is_full_name_token(self, token: str) -> bool: + """Return whether a token is a full name rather than an initial.""" + cleaned = self._clean_name_token(token) + return bool( + len(self._compact_key(cleaned)) > 1 + and not self._is_initial(cleaned) + and any(character.isalpha() for character in cleaned) + and all(self._allowed_name_character(character) for character in cleaned), + ) + + def _strong_particle_width(self, tokens: list[_Token], start: int) -> int: + keys = tuple(self._particle_key(token.text) for token in tokens) + for span in _STRONG_FAMILY_PARTICLE_SPANS: + end = start + len(span) + if keys[start:end] == span and end < len(tokens) and all(token.text.islower() for token in tokens[start:end]): + return len(span) + return 0 + + def _find_strong_particle_span(self, tokens: list[_Token], *, start: int = 1) -> tuple[int, int] | None: + for index in range(start, len(tokens)): + if width := self._strong_particle_width(tokens, index): + return index, width + return None + + def _particle_surname_first_roles( + self, + tokens: list[_Token], + ) -> tuple[list[_Token], list[_Token], list[_Token]] | None: + """Parse ``Carvalho da Silva Roberto José`` behind an exact strong span.""" + if len(tokens) < _FOUR_COMPONENTS or not self._is_full_name_token(tokens[0].text): + return None + particle_width = self._strong_particle_width(tokens, 1) + if not particle_width: + return None + family_end = _TWO_COMPONENTS + particle_width + trailing = tokens[family_end:] + if len(trailing) != _TWO_COMPONENTS or not all(self._is_full_name_token(token.text) for token in trailing): + return None + return ( + [replace(trailing[0], source_role="given")], + [replace(trailing[1], source_role="middle")], + [replace(token, source_role="surname") for token in tokens[:family_end]], + ) + + def _is_packed_surname_first_initials(self, tokens: list[_Token]) -> bool: + return bool( + len(tokens) >= _THREE_COMPONENTS + and self._is_full_name_token(tokens[0].text) + and all(token.text.endswith(".") and self._is_initial(token.text) for token in tokens[1:]), + ) + + def _infer_regular_roles(self, tokens: list[_Token]) -> tuple[list[_Token], list[_Token], list[_Token]]: + if len(tokens) == 1: + return [replace(tokens[0], source_role="given")], [], [] + + if particle_roles := self._particle_surname_first_roles(tokens): + return particle_roles + + if self._is_packed_surname_first_initials(tokens): + return ( + [replace(tokens[1], source_role="given")], + [replace(token, source_role="middle") for token in tokens[2:]], + [replace(tokens[0], source_role="surname")], + ) + + surname_start = len(tokens) - 1 + strong_particle = self._find_strong_particle_span(tokens) + if strong_particle is not None and (strong_particle[0] > _TWO_COMPONENTS or self._is_initial(tokens[0].text)): + surname_start = strong_particle[0] - 1 + elif particle_positions := [ + index for index, token in enumerate(tokens[1:-1], start=1) if self._particle_key(token.text) in _FAMILY_PARTICLES + ]: + surname_start = particle_positions[0] + elif len(tokens) == _FOUR_COMPONENTS and self._is_initial(tokens[1].text) and not self._is_initial(tokens[2].text): + surname_start = 2 + given = [replace(tokens[0], source_role="given")] + middle = [replace(token, source_role="middle") for token in tokens[1:surname_start]] + surname = [replace(token, source_role="surname") for token in tokens[surname_start:]] + return given, middle, surname + + def _structured_particle_surname_first_roles( + self, + given: list[_Token], + middle: list[_Token], + surname: list[_Token], + ) -> tuple[list[_Token], list[_Token], list[_Token]] | None: + """Repair one mechanically shifted surname-first strong-particle layout.""" + if ( + len(given) != 1 + or len(surname) != 1 + or not self._is_full_name_token(given[0].text) + or not self._is_full_name_token(surname[0].text) + ): + return None + particle_width = self._strong_particle_width(middle, 0) if middle else 0 + family_end = particle_width + 1 + if not particle_width or family_end >= len(middle): + return None + trailing = middle[family_end:] + if len(trailing) != 1 or not self._is_full_name_token(trailing[0].text): + return None + return ( + [replace(trailing[0], source_role="given")], + [replace(surname[0], source_role="middle")], + [ + replace(given[0], source_role="surname"), + *(replace(token, source_role="surname") for token in middle[:family_end]), + ], + ) + + def _expand_structured_surname_floor( + self, + given: list[_Token], + middle: list[_Token], + surname: list[_Token], + ) -> tuple[list[_Token], list[_Token]]: + """Move a strong particle span and its preceding anchor into surname.""" + if not given or not middle or not surname: + return middle, surname + combined = [*middle, *surname] + strong_particle = self._find_strong_particle_span(combined) + if strong_particle is None or strong_particle[0] >= len(middle): + return middle, surname + surname_start = strong_particle[0] - 1 + if surname_start < 0 or (surname_start == 0 and not all(self._is_initial(token.text) for token in given)): + return middle, surname + return ( + [replace(token, source_role="middle") for token in middle[:surname_start]], + [replace(token, source_role="surname") for token in [*middle[surname_start:], *surname]], + ) + + def _repair_structured_roles( # noqa: C901, PLR0911 + self, + by_role: dict[str, list[_Token]], + ) -> tuple[list[_Token], list[_Token], list[_Token]]: + """Preserve surviving source roles, repairing only structurally empty boundaries.""" + given = [replace(token, source_role="given") for token in by_role["given"]] + middle = [replace(token, source_role="middle") for token in by_role["middle"]] + surname = [replace(token, source_role="surname") for token in by_role["surname"]] + if shifted := self._structured_particle_surname_first_roles(given, middle, surname): + return shifted + if surname: + if given: + middle, surname = self._expand_structured_surname_floor(given, middle, surname) + return given, middle, surname + if middle: + return [replace(middle[0], source_role="given")], middle[1:], surname + if len(surname) == 1: + fused = _FUSED_INITIAL_SURNAME_RE.fullmatch(surname[0].text) + if fused is not None and all(self._allowed_name_character(character) for character in fused.group(2)): + initial, family = fused.groups() + return ( + [replace(surname[0], text=f"{initial}.", source_role="given")], + [], + [ + replace( + surname[0], + text=family, + source_role="surname", + position=surname[0].position + len(initial) + 1, + ), + ], + ) + if len(surname) >= _TWO_COMPONENTS and self._particle_key(surname[0].text) not in _FAMILY_PARTICLES: + if surname[-1].text.isupper() and not any(self._is_initial(token.text) for token in surname[:-1]): + return ( + [replace(token, source_role="given") for token in surname[:-1]], + [], + [replace(surname[-1], source_role="surname")], + ) + return self._infer_regular_roles(surname) + return [], [], surname + + surviving_groups = [group for group in (given, middle) if group] + if len(surviving_groups) == _TWO_COMPONENTS: + return ( + [replace(token, source_role="given") for token in surviving_groups[0]], + [], + [replace(token, source_role="surname") for token in surviving_groups[1]], + ) + if not surviving_groups: + return [], [], [] + + tokens = surviving_groups[0] + if len(tokens) == 1: + return [replace(tokens[0], source_role="given")], [], [] + return self._infer_regular_roles(tokens) + + def _invalid_token_reason(self, tokens: list[_Token]) -> str | None: + for token in tokens: + cleaned = self._clean_name_token(token.text) + if not cleaned: + return f"empty name token at position {token.position}" + if self._is_parenthesized_name_token(cleaned): + continue + if not any(character.isalpha() for character in cleaned): + return f"name token has no letters: {token.text!r}" + if cleaned[0] in "-'" or cleaned[-1] == "-": + return f"name joiner is not between letters: {token.text!r}" + if any(not self._allowed_name_character(character) for character in cleaned): + return f"unsupported character in name token: {token.text!r}" + return None + + @staticmethod + def _allowed_name_character(character: str) -> bool: + return character.isalpha() or unicodedata.category(character).startswith("M") or character in "-'." + + def _canonical_tokens(self, tokens: list[_Token], role: str) -> tuple[str, ...]: + return tuple(self._canonicalize_token(token.text, role) for token in tokens) + + def _canonicalize_token(self, token: str, role: str) -> str: # noqa: C901 + cleaned = self._clean_name_token(token) + if role in {"middle", "surname"} and cleaned in _LOWERCASE_RELATIONAL_TOKENS: + return cleaned + if _INITIAL_RE.fullmatch(cleaned): + return cleaned[0].upper() + "." + if _MULTI_INITIAL_RE.fullmatch(cleaned) or self._is_uppercase_dotted_abbreviation(cleaned): + return "".join(character.upper() if character.isalpha() else character for character in cleaned) + if ( + role != "surname" + and cleaned.isalpha() + and cleaned.isupper() + and len(cleaned) == _TWO_COMPONENTS + and not self._is_ambiguous_credential(cleaned) + ): + return cleaned + + parts = re.split(r"([-'])", cleaned) + canonical: list[str] = [] + preserve_mixed_case = not (cleaned.islower() or cleaned.isupper()) + capitalize_after_apostrophe = parts[0].casefold() in {"a", "d", "o"} + for index, part in enumerate(parts): + if part in {"-", "'"}: + canonical.append(part) + continue + if not part: + continue + particle = self._particle_key(part) in _FAMILY_PARTICLES + preserve_lower_particle = particle and part.islower() + normalize_surname_particle = role == "surname" and particle and part.isupper() + if (preserve_lower_particle or normalize_surname_particle) and (len(parts) == 1 or index < len(parts) - 1): + canonical.append(part.casefold()) + elif capitalize_after_apostrophe and index >= _TWO_COMPONENTS and parts[index - 1] == "'": + canonical.append(self._name_case(part)) + elif preserve_mixed_case and not self._looks_like_mixed_ocr_case(part): + canonical.append(part) + else: + canonical.append(self._name_case(part)) + return "".join(canonical) + + @staticmethod + def _name_case(part: str) -> str: + if len(part) == 1: + return part.upper() + if part.startswith("Mc") and len(part) > _MC_PREFIX_LENGTH and part[2:].isupper(): + return part[:2] + part[2].upper() + part[3:].lower() + mixed_ocr_case = PersonNameNormalizationService._looks_like_mixed_ocr_case(part) + if not (part.islower() or part.isupper() or mixed_ocr_case): + return part + lowered_tail = part[1:].lower().replace("i\u0307", "i") + cased = part[0].upper() + lowered_tail + if cased.startswith("Mc") and len(cased) > _MC_PREFIX_LENGTH: + cased = cased[:2] + cased[2].upper() + cased[3:] + return cased + + @staticmethod + def _looks_like_mixed_ocr_case(part: str) -> bool: + """Return whether a token has an initial mixed-case prefix plus an all-caps OCR tail.""" + return bool( + (part.startswith("Mc") and len(part) > _MC_PREFIX_LENGTH and part[2:].isupper()) + or (len(part) > _TWO_COMPONENTS and part[0].isupper() and part[1].islower() and part[2:].isupper()), + ) + + @staticmethod + def _clean_name_token(token: str) -> str: + if PersonNameNormalizationService._is_parenthesized_name_token(token): + return token + cleaned = token.strip('"()[]{}<>:;,\u201c\u201d') + if cleaned.endswith(".") and not ( + _INITIAL_RE.fullmatch(cleaned) + or _MULTI_INITIAL_RE.fullmatch(cleaned) + or _ABBREVIATED_TOKEN_RE.fullmatch(cleaned) + or PersonNameNormalizationService._is_dotted_letter_token(cleaned) + or _COMPOUND_INITIAL_RE.fullmatch(cleaned) + or PersonNameNormalizationService._compact_key(cleaned) in _LONG_NAME_ABBREVIATION_KEYS + ): + cleaned = cleaned[:-1] + return cleaned + + @staticmethod + def _is_parenthesized_name_token(token: str) -> bool: + """Return whether a token is one balanced parenthesized personal-name alternative.""" + if len(token) < _MIN_PARENTHESIZED_TOKEN_LENGTH or not token.startswith("(") or not token.endswith(")"): + return False + inner = token[1:-1] + return any(character.isalpha() for character in inner) and all( + PersonNameNormalizationService._allowed_name_character(character) for character in inner + ) + + @staticmethod + def _is_dotted_letter_token(token: str) -> bool: + """Return whether a token contains only letters and at least one internal dot.""" + return "." in token[:-1] and all(character.isalpha() or character == "." for character in token) + + @staticmethod + def _is_uppercase_dotted_abbreviation(token: str) -> bool: + """Preserve short abbreviations whose source already supplies uppercase letters.""" + letters = token[:-1] if token.endswith(".") else "" + return bool( + letters and len(letters) <= _MAX_DOTTED_ABBREVIATION_LETTERS and letters.isalpha() and letters.isupper(), + ) + + @staticmethod + def _particle_key(token: str) -> str: + return token.strip(".").casefold() + + def _canonical_suffix(self, token: str, *, explicit: bool) -> str: + stripped = token.strip(".,") + key = stripped.casefold() + standard = _STANDARD_SUFFIXES.get(key) + if standard is not None: + return standard + roman = stripped.upper() + supported = _EXPLICIT_ROMAN_SUFFIXES if explicit else _RAW_ROMAN_SUFFIXES + if roman in supported and (explicit or stripped == roman or roman in _CASE_INSENSITIVE_ROMAN_SUFFIXES): + return roman + return "" + + def _is_credential(self, token: str, *, explicit_context: bool = False) -> bool: + key = self._compact_key(token) + if key not in _CREDENTIAL_KEYS: + return False + if key not in _AMBIGUOUS_CREDENTIAL_KEYS or explicit_context: + return True + letters = "".join(character for character in token if character.isalpha()) + return "." in token or letters.isupper() or token.strip(".,") == _MIXED_CASE_CREDENTIALS.get(key) + + def _is_ambiguous_credential(self, token: str) -> bool: + return self._compact_key(token) in _AMBIGUOUS_CREDENTIAL_KEYS + + def _is_spaced_credential_group(self, tokens: list[_Token]) -> bool: + if not _TWO_COMPONENTS <= len(tokens) <= _MAX_SPACED_CREDENTIAL_TOKENS: + return False + if not all("." in token.text and self._compact_key(token.text) for token in tokens): + return False + return "".join(self._compact_key(token.text) for token in tokens) in _CREDENTIAL_KEYS + + def _trailing_spaced_credential_width(self, tokens: list[_Token]) -> int: + max_width = min(_MAX_SPACED_CREDENTIAL_TOKENS, len(tokens) - _TWO_COMPONENTS) + for width in range(max_width, _TWO_COMPONENTS - 1, -1): + if self._is_spaced_credential_group(tokens[-width:]): + return width + return 0 + + def _components( + self, + given: list[_Token], + middle: list[_Token], + surname: list[_Token], + suffix: list[_Token], + *, + order: tuple[str, ...] | None = None, + ) -> NameComponents: + if order is None: + order = tuple( + ["given"] * len(given) + ["middle"] * len(middle) + ["surname"] * len(surname) + ["suffix"] * len(suffix), + ) + + def source_text(token: _Token) -> str: + return token.source_text or token.text + + return NameComponents( + given_name=" ".join(source_text(token) for token in given), + middle_name=" ".join(source_text(token) for token in middle), + surname=" ".join(source_text(token) for token in surname), + suffix=" ".join(source_text(token) for token in suffix), + given_tokens=tuple(source_text(token) for token in given), + middle_tokens=tuple(source_text(token) for token in middle), + surname_tokens=tuple(source_text(token) for token in surname), + suffix_tokens=tuple(source_text(token) for token in suffix), + order=order, + ) + + @staticmethod + def _structured_source_order(by_role: dict[str, list[_Token]]) -> tuple[str, ...]: + return tuple(role for role in ("given", "middle", "surname", "suffix") for _token in by_role[role]) + + def _looks_like_two_complete_names(self, left: list[_Token], right: list[_Token]) -> bool: + if len(left) < _TWO_COMPONENTS or len(right) < _TWO_COMPONENTS: + return False + if any(self._particle_key(token.text) in _FAMILY_PARTICLES for token in left[:-1]): + return False + return not any(self._is_initial(token.text) for token in [*left, *right]) + + def _non_person_reason(self, surface: str) -> str | None: + lowered = surface.casefold() + if "@" in surface or "://" in surface: + return "contact or URL input" + if any(separator in surface for separator in (";", "&", "|")): + return "multiple-name separator" + and_match = _WORD_AND_RE.search(surface) + if and_match is not None and and_match.start() > 0: + return "author-list connector" + words = {self._compact_key(token) for token in _TOKEN_RE.findall(lowered)} + if words & _ORGANIZATION_WORDS: + return "organization input" + return None + + @staticmethod + def _is_initial(token: str) -> bool: + cleaned = PersonNameNormalizationService._clean_name_token(token) + return bool( + (len(cleaned) == 1 and cleaned.isalpha()) or _INITIAL_RE.fullmatch(cleaned) or _MULTI_INITIAL_RE.fullmatch(cleaned), + ) + + def _public_dropped(self, dropped: list[_DroppedToken]) -> tuple[DroppedNameToken, ...]: + return tuple( + DroppedNameToken(item.token.text, item.token.source_role, item.reason) + for item in sorted(dropped, key=lambda item: item.token.position) + ) + + @staticmethod + def _infer_dropped_roles(dropped: list[_DroppedToken], assigned: list[_Token]) -> list[_DroppedToken]: + inferred: list[_DroppedToken] = [] + for item in dropped: + if item.token.source_role: + inferred.append(item) + continue + if item.reason in {DropReason.TITLE, DropReason.CONNECTOR}: + role = "given" + elif item.reason is DropReason.CREDENTIAL: + role = "suffix" + else: + prior = [token for token in assigned if token.position <= item.token.position] + role = prior[-1].source_role if prior else "suffix" + inferred.append(replace(item, token=replace(item.token, source_role=role))) + return inferred + + def _invalid( + self, + reason: str, + dropped: list[_DroppedToken] | None = None, + ) -> PersonNameNormalizationResult: + return PersonNameNormalizationResult( + outcome=PersonNameOutcome.INVALID, + reason=reason, + dropped_tokens=self._public_dropped(dropped or []), + ) + + @staticmethod + def _non_person(reason: str) -> PersonNameNormalizationResult: + return PersonNameNormalizationResult(outcome=PersonNameOutcome.NON_PERSON, reason=reason) + + +__all__ = [ + "DropReason", + "DroppedNameToken", + "PersonNameNormalizationResult", + "PersonNameNormalizationService", + "PersonNameOutcome", +] diff --git a/sinonym/timo/config.yaml b/sinonym/timo/config.yaml index da69c43..c2014e8 100644 --- a/sinonym/timo/config.yaml +++ b/sinonym/timo/config.yaml @@ -20,3 +20,23 @@ model_variants: cuda: False integration_test: sinonym.timo.integration_test.TestRoutingIntegration docker_run_commands: [] + sinonym_v2: + instance: sinonym.timo.interface.Instance + prediction: sinonym.timo.interface.PredictionV2 + predictor: sinonym.timo.interface.PredictorV2 + predictor_config: sinonym.timo.interface.PredictorConfig + artifacts_s3_path: null + python_version: "3.10" + cuda: False + integration_test: sinonym.timo.integration_test.TestIntegrationV2 + docker_run_commands: [] + sinonym_routing_v2: + instance: sinonym.timo.interface.RoutingInstance + prediction: sinonym.timo.interface.RoutedPaperPredictionV2 + predictor: sinonym.timo.interface.RoutingPredictorV2 + predictor_config: sinonym.timo.interface.PredictorConfig + artifacts_s3_path: null + python_version: "3.10" + cuda: False + integration_test: sinonym.timo.integration_test.TestRoutingIntegrationV2 + docker_run_commands: [] diff --git a/sinonym/timo/integration_test.py b/sinonym/timo/integration_test.py index 22991bb..44c7e2b 100644 --- a/sinonym/timo/integration_test.py +++ b/sinonym/timo/integration_test.py @@ -3,12 +3,16 @@ from sinonym.timo.interface import ( Instance, Prediction, + PredictionV2, Predictor, PredictorConfig, + PredictorV2, RoutedPaperPrediction, + RoutedPaperPredictionV2, RoutedPrediction, RoutingInstance, RoutingPredictor, + RoutingPredictorV2, ) @@ -42,6 +46,8 @@ def test_batch_superset_output(self): self.assertIsNotNone(r.confidence) self.assertIsNotNone(r.format_pattern) # shared batch pattern replicated onto each row + assert results[0].format_pattern is not None + assert results[1].format_pattern is not None self.assertEqual( results[0].format_pattern.dominant_format, results[1].format_pattern.dominant_format, @@ -122,3 +128,38 @@ def test_empty_paper_still_emits_one_prediction(self): def test_predict_batch_empty(self): self.assertEqual(self.predictor.predict_batch([]), []) + + +class TestIntegrationV2(TestIntegration): + """Integration contract for the flat TIMO v2 variant.""" + + @classmethod + def setUpClass(cls): + cls.predictor = PredictorV2(config=PredictorConfig(), artifacts_dir=".") + + def test_non_chinese_canonical_name(self): + (result,) = self.predictor.predict_batch([Instance(name="Dr. Steve Marsh PhD")]) + + self.assertIsInstance(result, PredictionV2) + self.assertFalse(result.success) + self.assertIsNotNone(result.canonical_name) + assert result.canonical_name is not None + self.assertEqual(result.canonical_name.text, "Steve Marsh") + + +class TestRoutingIntegrationV2(TestRoutingIntegration): + """Integration contract for the routed TIMO v2 variant.""" + + @classmethod + def setUpClass(cls): + cls.predictor = RoutingPredictorV2(config=PredictorConfig(), artifacts_dir=".") + + def test_non_chinese_canonical_name(self): + (paper,) = self.predictor.predict_batch([RoutingInstance(pp_names=["Steve Blando IV"])]) + + self.assertIsInstance(paper, RoutedPaperPredictionV2) + canonical_name = paper.authors[0].canonical_name + self.assertIsNotNone(canonical_name) + assert canonical_name is not None + self.assertEqual(canonical_name.text, "Steve Blando IV") + self.assertEqual(canonical_name.normalized.suffix, "IV") diff --git a/sinonym/timo/interface.py b/sinonym/timo/interface.py index cee1feb..be62be3 100644 --- a/sinonym/timo/interface.py +++ b/sinonym/timo/interface.py @@ -1,4 +1,5 @@ from enum import Enum +from typing import cast from pydantic import BaseModel, BaseSettings, Field, root_validator @@ -122,6 +123,38 @@ class Prediction(TimoModel): format_pattern: FormatPattern | None = Field(default=None, description="shared batch order pattern (same on every row)") +class CanonicalNameComponents(TimoModel): + """Semantic name components plus their token and display-order lineage.""" + + given_name: str = "" + middle_name: str = "" + surname: str = "" + suffix: str = "" + given_tokens: list[str] = Field(default_factory=list) + middle_tokens: list[str] = Field(default_factory=list) + surname_tokens: list[str] = Field(default_factory=list) + suffix_tokens: list[str] = Field(default_factory=list) + order: list[str] = Field(default_factory=list) + + +class CanonicalNameValue(TimoModel): + """TIMO representation of an all-person canonical name.""" + + source_text: str + text: str + source: CanonicalNameComponents + normalized: CanonicalNameComponents + + +class PredictionV2(Prediction): + """Versioned prediction that adds canonical data without changing v1.""" + + canonical_name: CanonicalNameValue | None = Field( + default=None, + description="canonical representation for person names, including non-Chinese names", + ) + + class Candidate(TimoModel): surname_tokens: list[str] given_tokens: list[str] @@ -182,6 +215,18 @@ class BatchSummary(TimoModel): confidences: list[float] = Field(description="per-name confidence from individual_analyses, aligned with results") +class BatchPredictionV2(BatchPrediction): + """Full batch result with v2 per-name predictions.""" + + results: list[PredictionV2] + + +class BatchSummaryV2(BatchSummary): + """Trimmed batch result with v2 per-name predictions.""" + + results: list[PredictionV2] + + class RoutingDecisionValue(str, Enum): """Serialized pp-vys-abstain router decision.""" @@ -270,6 +315,27 @@ class RoutedPaperPrediction(TimoModel): ) +class RoutedPredictionV2(RoutedPrediction): + """Versioned routed result with canonical data on the answer and candidates.""" + + pp: PredictionV2 + vys: PredictionV2 | None = None + canonical_name: CanonicalNameValue | None = None + + +class PPRoutedPredictionV2(PPRoutedPrediction): + """Versioned PP-only routed result with canonical data.""" + + pp: PredictionV2 + canonical_name: CanonicalNameValue | None = None + + +class RoutedPaperPredictionV2(RoutedPaperPrediction): + """One paper's routed v2 results.""" + + authors: list[RoutedPredictionV2] = Field(default_factory=list) + + class PredictorConfig(BaseSettings): parallel: ParallelMode = "auto" mp_max_workers: int | None = None @@ -619,7 +685,7 @@ def _route_pp_vys_batches( success=bool(chosen is not None and chosen.success), **self._routed_name_fields(parsed), router_prediction=pred, - router_reason=row.get("router_reason", ""), + router_reason=cast("str", row.get("router_reason", "")), input_order_candidate=ioc, pp=self._to_prediction(pp_res, format_pattern=pp_fp.copy(deep=True)), vys=self._to_prediction(vys_res, format_pattern=vys_fp.copy(deep=True)), @@ -650,7 +716,7 @@ def _route_pp_batch(self, pp_batch: BatchParseResult) -> list[PPRoutedPrediction success=bool(parsed is not None), **self._routed_name_fields(parsed), router_prediction=pred, - router_reason=row.get("router_reason", ""), + router_reason=cast("str", row.get("router_reason", "")), pp=self._to_prediction(res, format_pattern=pp_fp.copy(deep=True)), ), ) @@ -704,7 +770,10 @@ class RoutingPredictor(Predictor): papers. Genuine routing errors also propagate. """ - def predict_batch(self, instances: list[RoutingInstance]) -> list[RoutedPaperPrediction]: # type: ignore[override] + def predict_batch( # ty: ignore[invalid-method-override] + self, + instances: list[RoutingInstance], + ) -> list[RoutedPaperPrediction]: predictions: list[RoutedPaperPrediction | None] = [None] * len(instances) batch_inputs: list[list[str]] = [] plans: list[tuple[int, str, int, int | None, int]] = [] @@ -754,3 +823,367 @@ def predict_batch(self, instances: list[RoutingInstance]) -> list[RoutedPaperPre message = "routing prediction plan did not fill every instance slot" raise RuntimeError(message) return [prediction for prediction in predictions if prediction is not None] + + +class PredictorV2(Predictor): + """TIMO v2 predictor surfacing all-person canonical name metadata. + + Chinese recognition fields and every routing decision continue to come from + the same detector and routing functions as v1. Only response conversion is + versioned, so v1 payloads and schemas remain unchanged. + """ + + def predict_batch(self, instances: list[Instance]) -> list[PredictionV2]: # ty: ignore[invalid-method-override] + """Analyze one flat TIMO batch and return v2 rows.""" + return cast("list[PredictionV2]", super().predict_batch(instances)) + + def analyze_name_batch( + self, + names: list[str], + format_threshold: float | None = None, + minimum_batch_size: int | None = None, + ) -> BatchPredictionV2: + """Return full batch analysis with v2 rows.""" + return cast( + "BatchPredictionV2", + super().analyze_name_batch(names, format_threshold, minimum_batch_size), + ) + + def process_name_batch( + self, + names: list[str], + format_threshold: float | None = None, + minimum_batch_size: int | None = None, + ) -> list[PredictionV2]: # ty: ignore[invalid-method-override] + """Process one batch and return v2 rows.""" + return cast( + "list[PredictionV2]", + super().process_name_batch(names, format_threshold, minimum_batch_size), + ) + + def process_name_batch_multiprocess( + self, + names: list[str], + max_workers: int | None = None, + chunk_size: int | None = None, + ) -> list[PredictionV2]: # ty: ignore[invalid-method-override] + """Process one batch through the multiprocess helper and return v2 rows.""" + return cast( + "list[PredictionV2]", + super().process_name_batch_multiprocess(names, max_workers, chunk_size), + ) + + def process_name_batches( # noqa: PLR0913 + self, + batches: list[list[str]], + *, + parallel: ParallelMode | None = None, + max_workers: int | None = None, + chunk_size: int | None = None, + min_parallel_batches: int | None = None, + format_threshold: float | None = None, + minimum_batch_size: int | None = None, + ) -> list[list[PredictionV2]]: # ty: ignore[invalid-method-override] + """Process multiple batches and return v2 rows.""" + return cast( + "list[list[PredictionV2]]", + super().process_name_batches( + batches, + parallel=parallel, + max_workers=max_workers, + chunk_size=chunk_size, + min_parallel_batches=min_parallel_batches, + format_threshold=format_threshold, + minimum_batch_size=minimum_batch_size, + ), + ) + + def score_name_batch( + self, + names: list[str], + format_threshold: float | None = None, + minimum_batch_size: int | None = None, + ) -> BatchSummaryV2: + """Return trimmed batch analysis with v2 rows.""" + return cast( + "BatchSummaryV2", + super().score_name_batch(names, format_threshold, minimum_batch_size), + ) + + def route_pp_vys( + self, + pp_names: list[str], + vys_pool_names: list[str], + ) -> list[RoutedPredictionV2]: # ty: ignore[invalid-method-override] + """Route one paper with venue context and return v2 rows.""" + return cast("list[RoutedPredictionV2]", super().route_pp_vys(pp_names, vys_pool_names)) + + def route_pp(self, names: list[str]) -> list[PPRoutedPredictionV2]: # ty: ignore[invalid-method-override] + """Route one PP-only batch and return v2 rows.""" + return cast("list[PPRoutedPredictionV2]", super().route_pp(names)) + + @staticmethod + def _to_canonical_components(components) -> CanonicalNameComponents: + return CanonicalNameComponents( + given_name=components.given_name, + middle_name=components.middle_name, + surname=components.surname, + suffix=components.suffix, + given_tokens=list(components.given_tokens), + middle_tokens=list(components.middle_tokens), + surname_tokens=list(components.surname_tokens), + suffix_tokens=list(components.suffix_tokens), + order=list(components.order), + ) + + def _to_canonical_name(self, canonical_name) -> CanonicalNameValue | None: + if canonical_name is None: + return None + return CanonicalNameValue( + source_text=canonical_name.source_text, + text=canonical_name.text, + source=self._to_canonical_components(canonical_name.source), + normalized=self._to_canonical_components(canonical_name.normalized), + ) + + @staticmethod + def _canonical_components_from_parsed(parsed, suffix: str = "") -> CanonicalNameComponents: + counts = { + "given": len(parsed.given_tokens), + "middle": len(parsed.middle_tokens), + "surname": len(parsed.surname_tokens), + } + occurrences = {role: parsed.order.count(role) for role in counts} + expanded_order: list[str] = [] + for role in parsed.order: + count = counts.get(role, 0) + if occurrences.get(role) == 1: + expanded_order.extend([role] * count) + elif count: + expanded_order.append(role) + if suffix: + expanded_order.append("suffix") + return CanonicalNameComponents( + given_name=parsed.given_name, + middle_name=parsed.middle_name, + surname=parsed.surname, + suffix=suffix, + given_tokens=list(parsed.given_tokens), + middle_tokens=list(parsed.middle_tokens), + surname_tokens=list(parsed.surname_tokens), + suffix_tokens=[suffix] if suffix else [], + order=expanded_order, + ) + + def _to_routed_canonical_name(self, parse_result, parsed) -> CanonicalNameValue | None: + """Convert canonical data, matching a PP-abstain input-order parse when needed.""" + canonical = self._to_canonical_name(parse_result.canonical_name) + if canonical is None or parsed is None or parsed is parse_result.parsed: + return canonical + + suffix = canonical.normalized.suffix + normalized = self._canonical_components_from_parsed(parsed, suffix=suffix) + text = " ".join( + component + for component in (normalized.given_name, normalized.middle_name, normalized.surname, normalized.suffix) + if component + ) + return CanonicalNameValue( + source_text=canonical.source_text, + text=text, + source=canonical.source, + normalized=normalized, + ) + + def _to_prediction( + self, + parse_result, + *, + confidence: float | None = None, + format_pattern: FormatPattern | None = None, + ) -> PredictionV2: + return PredictionV2( + success=parse_result.success, + error_message=parse_result.error_message, + given_name=parse_result.parsed.given_name if parse_result.parsed else None, + surname=parse_result.parsed.surname if parse_result.parsed else None, + middle_name=(parse_result.parsed.middle_name if parse_result.parsed and parse_result.parsed.middle_name else None), + confidence=confidence, + format_pattern=format_pattern, + canonical_name=self._to_canonical_name(parse_result.canonical_name), + ) + + def _to_batch_prediction(self, batch_result) -> BatchPredictionV2: + return BatchPredictionV2( + names=list(batch_result.names), + results=[self._to_prediction(result) for result in batch_result.results], + format_pattern=self._to_format_pattern(batch_result.format_pattern), + individual_analyses=[self._to_individual_analysis(analysis) for analysis in batch_result.individual_analyses], + improvements=list(batch_result.improvements), + name_order_evidence=[self._to_name_order_evidence(evidence) for evidence in batch_result.name_order_evidence], + ) + + def _to_batch_summary(self, batch_result) -> BatchSummaryV2: + return BatchSummaryV2( + names=list(batch_result.names), + results=[self._to_prediction(result) for result in batch_result.results], + format_pattern=self._to_format_pattern(batch_result.format_pattern), + confidences=[analysis.confidence for analysis in batch_result.individual_analyses], + ) + + def _route_pp_vys_batches( # ty: ignore[invalid-method-override] + self, + pp_batch: BatchParseResult, + pool: BatchParseResult, + n: int, + ) -> list[RoutedPredictionV2]: + """Route analyzed PP/VYS batches and convert their unchanged decisions to v2.""" + vys_batch = BatchParseResult( + names=list(pool.names[:n]), + results=list(pool.results[:n]), + format_pattern=pool.format_pattern, + individual_analyses=list(pool.individual_analyses[:n]), + improvements=[index for index in pool.improvements if index < n], + name_order_evidence=list(pool.name_order_evidence[:n]), + ) + rows = route_pp_vys_abstain_batches(pp_batch, vys_batch) + + pp_format = self._to_format_pattern(pp_batch.format_pattern) + vys_format = self._to_format_pattern(vys_batch.format_pattern) + output: list[RoutedPredictionV2] = [] + for index, row in enumerate(rows): + decision = row["router_prediction"] + input_order_candidate = row.get("input_order_candidate", "unknown") + pp_result = pp_batch.results[index] + vys_result = vys_batch.results[index] + if decision == "pp": + chosen = pp_result + elif decision == "vys": + chosen = vys_result + elif decision == "abstain": + chosen = {"pp": pp_result, "vys": vys_result}.get(input_order_candidate) + if chosen is None: + message = f"abstain with unexpected input_order_candidate={input_order_candidate!r} (expected 'pp'/'vys')" + raise ValueError(message) + elif decision == "not_person": + chosen = None + else: + message = f"pp-vys router returned unexpected router_prediction={decision!r}" + raise ValueError(message) + + parsed = chosen.parsed if (chosen is not None and chosen.success) else None + canonical_result = chosen or pp_result + output.append( + RoutedPredictionV2( + success=bool(chosen is not None and chosen.success), + **self._routed_name_fields(parsed), + router_prediction=decision, + router_reason=cast("str", row.get("router_reason", "")), + input_order_candidate=input_order_candidate, + pp=self._to_prediction(pp_result, format_pattern=pp_format.copy(deep=True)), + vys=self._to_prediction(vys_result, format_pattern=vys_format.copy(deep=True)), + canonical_name=self._to_routed_canonical_name(canonical_result, parsed), + ), + ) + return output + + def _route_pp_batch( # ty: ignore[invalid-method-override] + self, + pp_batch: BatchParseResult, + ) -> list[PPRoutedPredictionV2]: + """Route an analyzed PP-only batch and convert its unchanged decisions to v2.""" + rows = route_pp_abstain_rows(build_pp_abstain_rows(pp_batch, self._detector)) + pp_format = self._to_format_pattern(pp_batch.format_pattern) + + output: list[PPRoutedPredictionV2] = [] + for index, row in enumerate(rows): + decision = row["router_prediction"] + result = pp_batch.results[index] + if decision == "pp": + parsed = result.parsed if result.success else None + elif decision == "abstain": + parsed = pp_abstain_parsed(result, row) + elif decision == "not_person": + parsed = None + else: + message = f"pp-abstain router returned unexpected router_prediction={decision!r}" + raise ValueError(message) + output.append( + PPRoutedPredictionV2( + success=bool(parsed is not None), + **self._routed_name_fields(parsed), + router_prediction=decision, + router_reason=cast("str", row.get("router_reason", "")), + pp=self._to_prediction(result, format_pattern=pp_format.copy(deep=True)), + canonical_name=self._to_routed_canonical_name(result, parsed), + ), + ) + return output + + def route( # ty: ignore[invalid-method-override] + self, + pp_names: list[str], + vys_pool_names: list[str] | None = None, + ) -> list[RoutedPredictionV2]: + """Run the unchanged unified router and retain v2 canonical fields.""" + if vys_pool_names: + return self.route_pp_vys(pp_names, vys_pool_names) + return [RoutedPredictionV2(**result.dict(), input_order_candidate=None, vys=None) for result in self.route_pp(pp_names)] + + +class RoutingPredictorV2(PredictorV2): + """TIMO-served v2 routing variant with one prediction per paper.""" + + def predict_batch( # ty: ignore[invalid-method-override] + self, + instances: list[RoutingInstance], + ) -> list[RoutedPaperPredictionV2]: + predictions: list[RoutedPaperPredictionV2 | None] = [None] * len(instances) + batch_inputs: list[list[str]] = [] + plans: list[tuple[int, str, int, int | None, int]] = [] + + for instance_index, instance in enumerate(instances): + if not instance.pp_names: + predictions[instance_index] = RoutedPaperPredictionV2(authors=[]) + continue + + if instance.vys_pool_names: + self._validate_vys_pool_names(instance.pp_names, instance.vys_pool_names) + pp_batch_index = len(batch_inputs) + batch_inputs.append(instance.pp_names) + vys_batch_index = len(batch_inputs) + batch_inputs.append(instance.vys_pool_names) + plans.append((instance_index, "pp_vys", pp_batch_index, vys_batch_index, len(instance.pp_names))) + else: + pp_batch_index = len(batch_inputs) + batch_inputs.append(instance.pp_names) + plans.append((instance_index, "pp_only", pp_batch_index, None, 0)) + + batch_results = self._detector.analyze_name_batches( + batch_inputs, + parallel=self._config.parallel, + min_parallel_batches=self._config.mp_min_parallel_batches, + max_workers=self._config.mp_max_workers, + chunk_size=self._config.mp_chunk_size, + mp_start_method=self._config.mp_start_method, + ) + + for instance_index, mode, pp_batch_index, vys_batch_index, pp_count in plans: + if mode == "pp_vys": + if vys_batch_index is None: + message = "pp_vys routing plan missing VYS batch index" + raise RuntimeError(message) + authors = self._route_pp_vys_batches( + batch_results[pp_batch_index], + batch_results[vys_batch_index], + pp_count, + ) + else: + pp_only = self._route_pp_batch(batch_results[pp_batch_index]) + authors = [RoutedPredictionV2(**result.dict(), input_order_candidate=None, vys=None) for result in pp_only] + predictions[instance_index] = RoutedPaperPredictionV2(authors=authors) + + if any(prediction is None for prediction in predictions): + message = "routing prediction plan did not fill every instance slot" + raise RuntimeError(message) + return [prediction for prediction in predictions if prediction is not None] diff --git a/tests/test_bug_report_fixes.py b/tests/test_bug_report_fixes.py index 0ec4981..70a0004 100644 --- a/tests/test_bug_report_fixes.py +++ b/tests/test_bug_report_fixes.py @@ -14,7 +14,7 @@ route_pp_vys_abstain_rows, ) from sinonym.services.batch_analysis import LATIN_ONLY_REPRESENTATION, BatchCandidateEntry -from sinonym.timo.interface import Instance, Predictor, PredictorConfig +from sinonym.timo.interface import Instance, Predictor, PredictorConfig, PredictorV2 def _pp_abstain_row(**overrides): @@ -266,13 +266,46 @@ def test_route_pp_preserves_latin_compound_surname_first_parse(): def test_internal_compound_surname_span_reports_real_width(detector): batch = detector.analyze_name_batch(["Wei Zhu Ge Ming"]) + result = batch.results[0] evidence = batch.name_order_evidence[0] - assert batch.results[0].success + assert result.success + assert result.result == "Wei-Ming Zhu Ge" + assert result.parsed is not None + assert result.parsed.surname_tokens == ["Zhu", "Ge"] + assert result.parsed.given_tokens == ["Wei", "Ming"] assert evidence.selected_surname_position == "internal" assert evidence.selected_surname_token_count == 2 +def test_internal_compound_surname_fix_propagates_through_pp_only_routing(): + predictor = Predictor(PredictorConfig(parallel="never"), "") + + routed = predictor.route_pp(["Wei Zhu Ge Ming"])[0] + + assert routed.router_prediction.value == "abstain" + assert routed.router_reason == "weak_zero_batch" + assert (routed.given_name, routed.middle_name, routed.surname) == ("Wei-Ming", None, "Zhu Ge") + assert (routed.pp.given_name, routed.pp.middle_name, routed.pp.surname) == ("Wei-Ming", None, "Zhu Ge") + + +def test_internal_compound_surname_fix_propagates_to_v2_canonical_name(): + predictor = PredictorV2(PredictorConfig(parallel="never"), "") + + routed = predictor.route_pp(["Wei Zhu Ge Ming"])[0] + + assert routed.router_prediction.value == "abstain" + assert routed.router_reason == "weak_zero_batch" + assert (routed.given_name, routed.middle_name, routed.surname) == ("Wei-Ming", None, "Zhu Ge") + assert routed.canonical_name is not None + assert routed.canonical_name.text == "Wei-Ming Zhu Ge" + assert ( + routed.canonical_name.normalized.given_name, + routed.canonical_name.normalized.middle_name, + routed.canonical_name.normalized.surname, + ) == ("Wei-Ming", "", "Zhu Ge") + + @pytest.mark.parametrize( ("raw_name", "expected_result", "expected_surname", "expected_given"), [ diff --git a/tests/test_canonical_name_integration.py b/tests/test_canonical_name_integration.py new file mode 100644 index 0000000..13d7dfd --- /dev/null +++ b/tests/test_canonical_name_integration.py @@ -0,0 +1,118 @@ +"""Public canonical-name behavior without changing legacy Chinese recognition.""" + +from __future__ import annotations + + +def test_non_chinese_result_surfaces_canonical_name_without_legacy_success(detector): + result = detector.normalize_name("Dr. Steve Marsh PhD") + + assert not result.success + assert result.result == "" + assert result.parsed is None + assert result.canonical_name is not None + assert result.canonical_name.text == "Steve Marsh" + assert result.canonical_name.normalized.given_name == "Steve" + assert result.canonical_name.normalized.middle_name == "" + assert result.canonical_name.normalized.surname == "Marsh" + assert result.canonical_name.normalized.suffix == "" + + +def test_canonical_name_normalizes_all_joiner_variants(detector): + result = detector.normalize_name("Ms. Ana\u2013Maria O\u2019Neill MS") + + assert not result.success + assert result.canonical_name is not None + assert result.canonical_name.text == "Ana-Maria O'Neill" + assert result.canonical_name.normalized.given_tokens == ("Ana-Maria",) + assert result.canonical_name.normalized.surname_tokens == ("O'Neill",) + + +def test_canonical_name_moves_true_suffix_to_suffix_component(detector): + result = detector.normalize_name("Steve Blando IV") + + assert not result.success + assert result.canonical_name is not None + assert result.canonical_name.text == "Steve Blando IV" + assert result.canonical_name.normalized.given_name == "Steve" + assert result.canonical_name.normalized.surname == "Blando" + assert result.canonical_name.normalized.suffix == "IV" + assert result.canonical_name.normalized.order == ("given", "surname", "suffix") + + +def test_structured_component_normalization_repairs_roles_after_drops(detector): + canonical = detector.normalize_person_name_components( + first_name="dr steve", + middle_name="marsh", + last_name="phd", + ) + + assert canonical is not None + assert canonical.text == "Steve Marsh" + assert canonical.normalized.given_name == "Steve" + assert canonical.normalized.middle_name == "" + assert canonical.normalized.surname == "Marsh" + assert canonical.normalized.suffix == "" + + +def test_explicit_family_first_comma_is_rendered_in_canonical_order(detector): + canonical = detector.normalize_person_name("Smith, John Q. Jr.") + + assert canonical is not None + assert canonical.text == "John Q. Smith Jr." + assert canonical.source.order == ("surname", "given", "middle", "suffix") + assert canonical.normalized.order == ("given", "middle", "surname", "suffix") + + +def test_chinese_result_canonical_name_matches_selected_parse(detector): + result = detector.normalize_name("Wei Zhu Ge Ming") + + assert result.success + assert result.result == "Wei-Ming Zhu Ge" + assert result.parsed is not None + assert result.canonical_name is not None + assert result.canonical_name.text == result.result + assert result.canonical_name.normalized.given_name == result.parsed.given_name + assert result.canonical_name.normalized.surname == result.parsed.surname + assert result.canonical_name.normalized.given_tokens == tuple(result.parsed.given_tokens) + assert result.canonical_name.normalized.surname_tokens == tuple(result.parsed.surname_tokens) + + +def test_chinese_canonical_source_preserves_fused_token_lineage(detector): + result = detector.normalize_name("Wang Weiming") + + assert result.canonical_name is not None + assert result.canonical_name.source.given_tokens == ("Weiming",) + assert result.canonical_name.source.surname_tokens == ("Wang",) + assert result.canonical_name.source.order == ("surname", "given") + assert result.canonical_name.normalized.given_tokens == ("Wei", "Ming") + + +def test_batch_results_surface_canonical_name_after_final_selection(detector): + batch = detector.analyze_name_batch(["Li Wei", "John Smith"]) + + chinese, western = batch.results + assert chinese.parsed is not None + assert chinese.canonical_name is not None + assert chinese.canonical_name.text == chinese.result + assert chinese.canonical_name.normalized.given_name == chinese.parsed.given_name + assert chinese.canonical_name.normalized.surname == chinese.parsed.surname + assert western.canonical_name is not None + assert western.canonical_name.text == "John Smith" + assert not western.success + assert western.parsed is None + + +def test_invalid_and_obvious_non_person_inputs_have_no_canonical_name(detector): + assert detector.normalize_name("").canonical_name is None + assert detector.normalize_name("---").canonical_name is None + assert detector.normalize_name("Veecon Music & Entertainment").canonical_name is None + assert detector.normalize_name("北京大学").canonical_name is None + + +def test_korean_native_name_surfaces_semantic_family_first_canonical_name(detector): + result = detector.normalize_name("김민준") + + assert not result.success + assert result.canonical_name is not None + assert result.canonical_name.text == "민준 김" + assert result.canonical_name.source.order == ("surname", "given") diff --git a/tests/test_canonical_results.py b/tests/test_canonical_results.py new file mode 100644 index 0000000..2479e83 --- /dev/null +++ b/tests/test_canonical_results.py @@ -0,0 +1,86 @@ +"""Tests for canonical-name result metadata.""" + +from dataclasses import FrozenInstanceError + +import pytest + +from sinonym.coretypes import CanonicalName, NameComponents, ParsedName, ParseResult + + +def _canonical_name(text: str = "Steve Marsh Blando IV") -> CanonicalName: + source = NameComponents( + given_name="dr steve", + middle_name="marsh", + surname="blando", + suffix="IV", + given_tokens=("dr", "steve"), + middle_tokens=("marsh",), + surname_tokens=("blando",), + suffix_tokens=("IV",), + order=("given", "given", "middle", "surname", "suffix"), + ) + normalized = NameComponents( + given_name="Steve", + middle_name="Marsh", + surname="Blando", + suffix="IV", + given_tokens=("Steve",), + middle_tokens=("Marsh",), + surname_tokens=("Blando",), + suffix_tokens=("IV",), + order=("given", "middle", "surname", "suffix"), + ) + return CanonicalName(source_text="dr steve marsh blando IV", text=text, source=source, normalized=normalized) + + +def test_canonical_name_and_components_are_deeply_immutable() -> None: + canonical_name = _canonical_name() + text_attribute = "text" + surname_attribute = "surname" + append_method = "append" + + with pytest.raises(FrozenInstanceError): + setattr(canonical_name, text_attribute, "Changed") + with pytest.raises(FrozenInstanceError): + setattr(canonical_name.normalized, surname_attribute, "Changed") + with pytest.raises(AttributeError): + getattr(canonical_name.normalized.surname_tokens, append_method)("Changed") + + +def test_parse_result_positional_constructor_remains_compatible() -> None: + parsed = ParsedName("Zhang", "Wei", ["Zhang"], ["Wei"]) + + result = ParseResult(True, "Wei Zhang", None, None, parsed, parsed) + + assert result.parsed is parsed + assert result.parsed_original_order is parsed + assert result.canonical_name is None + + +def test_map_preserves_canonical_name_on_success_and_callback_failure() -> None: + canonical_name = _canonical_name() + result = ParseResult.success_with_name("Steve Marsh Blando IV", canonical_name=canonical_name) + + mapped = result.map(str.upper) + failed = result.map(lambda _value: 1 / 0) + + assert mapped.result == "STEVE MARSH BLANDO IV" + assert mapped.canonical_name is canonical_name + assert not failed.success + assert failed.canonical_name is canonical_name + + +def test_flat_map_inherits_canonical_name_without_replacing_callback_metadata() -> None: + canonical_name = _canonical_name() + replacement = _canonical_name("Stephen Blando") + result = ParseResult.success_with_name("Steve Marsh Blando IV", canonical_name=canonical_name) + + inherited = result.flat_map(lambda _value: ParseResult.success_with_name("Steve Blando")) + inherited_failure = result.flat_map(lambda _value: ParseResult.failure("not normalized")) + replaced = result.flat_map( + lambda _value: ParseResult.success_with_name("Stephen Blando", canonical_name=replacement), + ) + + assert inherited.canonical_name is canonical_name + assert inherited_failure.canonical_name is canonical_name + assert replaced.canonical_name is replacement diff --git a/tests/test_chinese_classification_evidence_gates.py b/tests/test_chinese_classification_evidence_gates.py new file mode 100644 index 0000000..45b630e --- /dev/null +++ b/tests/test_chinese_classification_evidence_gates.py @@ -0,0 +1,238 @@ +"""Regression tests for narrow cross-cultural Chinese classification evidence.""" + +import pytest + +from sinonym import ChineseNameDetector +from sinonym.services.person_name_normalization import DropReason, PersonNameNormalizationService, PersonNameOutcome + + +@pytest.fixture(scope="module") +def detector() -> ChineseNameDetector: + """Return one initialized detector for the evidence-gate cases.""" + return ChineseNameDetector() + + +@pytest.mark.parametrize( + ("raw_name", "expected"), + [ + ("Jun-Fun Horng", "Jun-Fun Horng"), + ("Shugi Hsien", "Shu-Gi Hsien"), + ("Zhou Df", "Df Zhou"), + ], +) +def test_reviewed_surname_alias_and_compact_initial_evidence_accepts_chinese( + detector: ChineseNameDetector, + raw_name: str, + expected: str, +) -> None: + result = detector.normalize_name(raw_name) + + assert result.success + assert result.result == expected + + +@pytest.mark.parametrize( + ("raw_name", "expected"), + [ + ("Yi-xin Han", "Yi-Xin Han"), + ("Peng-qian Han", "Peng-Qian Han"), + ], +) +def test_broad_korean_shape_is_not_used( + detector: ChineseNameDetector, + raw_name: str, + expected: str, +) -> None: + result = detector.normalize_name(raw_name) + + assert result.success + assert result.result == expected + + +@pytest.mark.parametrize( + "raw_name", + [ + "In-sun Yu", + "Jin Uk Ha", + "Ok Jeung", + ], +) +def test_directional_korean_surname_given_evidence_precedes_chinese_shortcuts( + detector: ChineseNameDetector, + raw_name: str, +) -> None: + result = detector.normalize_name(raw_name) + + assert not result.success + assert result.error_message == "Korean structural patterns detected" + + +@pytest.mark.parametrize( + "raw_name", + [ + "Aimin Yang", + "Bing Han", + "Boyi Kang", + "Chen-Yu Lee", + "Chia-Yuan Chang", + "Ching-Wen Yang", + "Chien-Yao Wang", + ], +) +def test_directional_korean_gate_preserves_reviewed_chinese_controls( + detector: ChineseNameDetector, + raw_name: str, +) -> None: + assert detector.normalize_name(raw_name).success + + +@pytest.mark.parametrize( + ("raw_name", "expected"), + [ + ("Jungting Yu", "Jung-Ting Yu"), + ("Tsung-Jr Chen", "Tsung-Jr Chen"), + ], +) +def test_contextual_taiwan_romanization_is_admitted_and_preserved( + detector: ChineseNameDetector, + raw_name: str, + expected: str, +) -> None: + result = detector.normalize_name(raw_name) + + assert result.success + assert result.result == expected + assert result.parsed_original_order is not None + assert result.parsed_original_order.order == ["given", "surname"] + + +@pytest.mark.parametrize( + "raw_name", + [ + "Jungting Kim", + "Tsung-Jr Kim", + "Jungting Nguyen", + "Tsung-Jr Tran", + "Robert Chen Jr", + "Jung Kim", + "Wade Wang", + "Sina Huang", + ], +) +def test_contextual_taiwan_gate_rejects_collision_controls( + detector: ChineseNameDetector, + raw_name: str, +) -> None: + assert not detector.normalize_name(raw_name).success + + +@pytest.mark.parametrize("raw_name", ["Jung Yu", "J.-W. Koo"]) +def test_bare_jung_and_initials_are_not_contextual_taiwan_evidence( + detector: ChineseNameDetector, + raw_name: str, +) -> None: + normalized = detector._normalizer.apply(raw_name) # noqa: SLF001 + + assert detector._ethnicity_service.contextual_taiwan_given_parts(normalized.roman_tokens) is None # noqa: SLF001 + + +def test_ordinary_short_non_chinese_name_is_not_treated_as_initial_bundle( + detector: ChineseNameDetector, +) -> None: + assert not detector.normalize_name("Sun Kim").success + + +def test_leading_et_al_contamination_is_removed_before_chinese_classification( + detector: ChineseNameDetector, +) -> None: + result = detector.normalize_name("Et al. Biquan Mo") + + assert result.success + assert result.result == "Bi-Quan Mo" + assert result.parsed is not None + assert (result.parsed.given_name, result.parsed.surname) == ("Bi-Quan", "Mo") + + +def test_leading_et_al_contamination_has_dropped_token_audit() -> None: + normalized = PersonNameNormalizationService().normalize_text("Et al. Biquan Mo") + structured = PersonNameNormalizationService().normalize_components( + first_name="Et", + middle_name="al.", + last_name="Biquan Mo", + ) + + assert normalized.outcome is PersonNameOutcome.PERSON + assert normalized.canonical_name is not None + assert normalized.canonical_name.text == "Biquan Mo" + assert [(item.text, item.source_role, item.reason) for item in normalized.dropped_tokens] == [ + ("Et", "given", DropReason.CONNECTOR), + ("al.", "given", DropReason.CONNECTOR), + ] + assert structured.outcome is PersonNameOutcome.PERSON + assert structured.canonical_name is not None + assert structured.canonical_name.text == "Biquan Mo" + assert [(item.text, item.source_role, item.reason) for item in structured.dropped_tokens] == [ + ("Et", "given", DropReason.CONNECTOR), + ("al.", "middle", DropReason.CONNECTOR), + ] + + +@pytest.mark.parametrize( + "raw_name", + [ + "Etienne Al", + "Et Al Biquan Mo", + "Biquan et al. Mo", + "Biquan Mo et al.", + "Et Albright Mo", + ], +) +def test_et_al_cleanup_requires_exact_leading_citation_prefix( + detector: ChineseNameDetector, + raw_name: str, +) -> None: + normalized = PersonNameNormalizationService().normalize_text(raw_name) + + assert not normalized.dropped_tokens + assert not detector.normalize_name(raw_name).success + + +@pytest.mark.parametrize( + ("raw_name", "expected", "expected_surname"), + [ + ("Peter Ch'en", "Peter Ch'en", "Ch'en"), + ("Ch'en Peter", "Peter Ch'en", "Ch'en"), + ("Peter Ts'ai", "Peter Ts'ai", "Ts'ai"), + ], +) +def test_wade_giles_apostrophe_surname_is_decisive_chinese_evidence( + detector: ChineseNameDetector, + raw_name: str, + expected: str, + expected_surname: str, +) -> None: + result = detector.normalize_name(raw_name) + + assert result.success + assert result.result == expected + assert result.parsed is not None + assert result.parsed.given_name == "Peter" + assert result.parsed.surname == expected_surname + + +@pytest.mark.parametrize( + "raw_name", + [ + "Peter O'Brien", + "D'Angelo Marie", + "Peter Ch'energy", + "Ch'energy Peter", + "Peter Ch'en-Smith", + "O'Ch'en Peter", + ], +) +def test_apostrophe_surname_evidence_requires_exact_wade_giles_token( + detector: ChineseNameDetector, + raw_name: str, +) -> None: + assert not detector.normalize_name(raw_name).success diff --git a/tests/test_east_asian_name_order.py b/tests/test_east_asian_name_order.py new file mode 100644 index 0000000..418aba1 --- /dev/null +++ b/tests/test_east_asian_name_order.py @@ -0,0 +1,123 @@ +"""Regression tests for conservative East Asian family-first routing.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from sinonym.services.east_asian_name_order import EastAsianNameOrderService + +if TYPE_CHECKING: + from sinonym import ChineseNameDetector + + +def test_japanese_romanized_routes_only_unambiguous_dictionary_direction( + detector: ChineseNameDetector, +) -> None: + routed = detector.normalize_person_name("Shirakawa Hideki") + ambiguous = detector.normalize_person_name("Motoki Kouzaki") + + assert routed is not None + assert routed.text == "Hideki Shirakawa" + assert routed.normalized.given_name == "Hideki" + assert routed.normalized.surname == "Shirakawa" + assert routed.source.order == ("surname", "given") + assert ambiguous is not None + assert ambiguous.text == "Motoki Kouzaki" + assert ambiguous.source.order == ("given", "surname") + + +def test_structured_japanese_romanized_matches_raw_directional_route( + detector: ChineseNameDetector, +) -> None: + raw = detector.normalize_person_name("Kumagai Jin") + structured = detector.normalize_person_name_components(first_name="Kumagai", last_name="Jin") + + assert raw is not None + assert structured is not None + assert structured.text == raw.text == "Jin Kumagai" + assert structured.normalized == raw.normalized + assert structured.source_text == "Kumagai Jin" + assert structured.source.given_name == "Kumagai" + assert structured.source.surname == "Jin" + assert structured.source.given_tokens == ("Kumagai",) + assert structured.source.surname_tokens == ("Jin",) + assert structured.source.order == ("given", "surname") + + +def test_structured_japanese_romanized_preserves_ambiguous_or_given_first_pairs( + detector: ChineseNameDetector, +) -> None: + for first_name, last_name in (("Motoki", "Kouzaki"), ("Akira", "Kurosawa")): + surface = f"{first_name} {last_name}" + raw = detector.normalize_person_name(surface) + structured = detector.normalize_person_name_components(first_name=first_name, last_name=last_name) + + assert raw is not None + assert structured is not None + assert structured.text == raw.text == surface + assert structured.normalized == raw.normalized + assert structured.source.given_name == first_name + assert structured.source.surname == last_name + assert structured.source.order == ("given", "surname") + + +def test_japanese_native_uses_classifier_then_component_boundary() -> None: + decision = EastAsianNameOrderService().infer( + "中田英寿", + japanese_probability=lambda _name: 1.0, + ) + + assert decision is not None + assert decision.first_name == "英寿" + assert decision.last_name == "中田" + assert decision.source_order == ("surname", "given") + + +def test_japanese_native_preserves_when_classifier_abstains() -> None: + decision = EastAsianNameOrderService().infer( + "中田英寿", + japanese_probability=lambda _name: 0.79, + ) + + assert decision is None + + +def test_korean_routes_strict_shapes_and_preserves_ambiguous_romanization( + detector: ChineseNameDetector, +) -> None: + romanized = detector.normalize_person_name("Kim Min-jun") + ambiguous = detector.normalize_person_name("Kim Yuna") + native = detector.normalize_person_name("김민수") + + assert romanized is not None + assert romanized.text == "Min-jun Kim" + assert romanized.source.order == ("surname", "given") + assert ambiguous is not None + assert ambiguous.text == "Kim Yuna" + assert native is not None + assert native.text == "민수 김" + assert native.source.order == ("surname", "given") + + +def test_vietnamese_requires_unicode_evidence_and_preserves_given_span( + detector: ChineseNameDetector, +) -> None: + unicode_name = detector.normalize_person_name("Nguyễn Văn An") + ascii_name = detector.normalize_person_name("Nguyen Van An") + + assert unicode_name is not None + assert unicode_name.text == "An Văn Nguyễn" + assert unicode_name.normalized.given_name == "An" + assert unicode_name.normalized.middle_name == "Văn" + assert unicode_name.normalized.surname == "Nguyễn" + assert unicode_name.source.order == ("surname", "middle", "given") + assert ascii_name is not None + assert ascii_name.text == "Nguyen Van An" + + +def test_comma_order_remains_authoritative(detector: ChineseNameDetector) -> None: + canonical = detector.normalize_person_name("Kim, Min-jun") + + assert canonical is not None + assert canonical.text == "Min-jun Kim" + assert canonical.source.order == ("surname", "given") diff --git a/tests/test_person_name_normalization.py b/tests/test_person_name_normalization.py new file mode 100644 index 0000000..b732625 --- /dev/null +++ b/tests/test_person_name_normalization.py @@ -0,0 +1,1278 @@ +"""Focused tests for canonical person-name normalization.""" + +from dataclasses import FrozenInstanceError + +import pytest + +from sinonym.services import person_name_normalization +from sinonym.services.person_name_normalization import ( + DropReason, + PersonNameNormalizationService, + PersonNameOutcome, +) + + +@pytest.fixture +def normalizer() -> PersonNameNormalizationService: + """Return the dependency-free canonical name normalizer.""" + return PersonNameNormalizationService() + + +@pytest.mark.parametrize( + "dash", + ["-", "\u2010", "\u2011", "\u2012", "\u2013", "\u2014", "\u2015", "\u2043", "\u2212", "\ufe58", "\ufe63", "\uff0d"], +) +def test_normalize_text_converts_dash_like_joiners( + normalizer: PersonNameNormalizationService, + dash: str, +) -> None: + result = normalizer.normalize_text(f"anne {dash} marie smith") + + assert result.outcome is PersonNameOutcome.PERSON + assert result.canonical_name is not None + assert result.canonical_name.text == "Anne-Marie Smith" + assert result.canonical_name.normalized.given_name == "Anne-Marie" + + +@pytest.mark.parametrize( + "apostrophe", + [ + "'", + "\u2018", + "\u2019", + "\u201a", + "\u201b", + "\u2032", + "\u2035", + "\u02bb", + "\u02bc", + "\u02b9", + "\ua78b", + "\ua78c", + "\uff07", + "`", + "\uff40", + "\u00b4", + ], +) +def test_normalize_text_converts_apostrophe_like_joiners( + normalizer: PersonNameNormalizationService, + apostrophe: str, +) -> None: + result = normalizer.normalize_text(f"sean o {apostrophe} connor") + + assert result.outcome is PersonNameOutcome.PERSON + assert result.canonical_name is not None + assert result.canonical_name.text == "Sean O'Connor" + assert result.canonical_name.normalized.surname == "O'Connor" + + +def test_nfkc_apostrophe_expansion_preserves_its_letter( + normalizer: PersonNameNormalizationService, +) -> None: + result = normalizer.normalize_text("sean o\u0149eill") + + assert result.outcome is PersonNameOutcome.PERSON + assert result.canonical_name is not None + assert result.canonical_name.text == "Sean O'Neill" + + +def test_ascii_surface_fast_path_skips_unicode_scans(monkeypatch: pytest.MonkeyPatch) -> None: + def unexpected_call(*_args: object, **_kwargs: object) -> str: + raise AssertionError + + class UnexpectedUnicodeData: + normalize = staticmethod(unexpected_call) + + monkeypatch.setattr(person_name_normalization, "unicodedata", UnexpectedUnicodeData) + monkeypatch.setattr(PersonNameNormalizationService, "_normalize_joiner", staticmethod(unexpected_call)) + + assert ( + PersonNameNormalizationService._normalize_surface( # noqa: SLF001 + " sean o ` connor ", + ) + == "sean o'connor" + ) + + +def test_normalize_text_strips_stacked_title_and_credentials_at_boundaries( + normalizer: PersonNameNormalizationService, +) -> None: + result = normalizer.normalize_text("Dr. Steve Marsh Blando, Ph.D., M.S.") + + assert result.outcome is PersonNameOutcome.PERSON + assert result.canonical_name is not None + assert result.canonical_name.text == "Steve Marsh Blando" + assert result.canonical_name.normalized.given_name == "Steve" + assert result.canonical_name.normalized.middle_name == "Marsh" + assert result.canonical_name.normalized.surname == "Blando" + assert [(token.text, token.source_role, token.reason) for token in result.dropped_tokens] == [ + ("Dr.", "given", DropReason.TITLE), + ("Ph.D.", "suffix", DropReason.CREDENTIAL), + ("M.S.", "suffix", DropReason.CREDENTIAL), + ] + + +@pytest.mark.parametrize( + ("first_name", "middle_name", "last_name", "expected", "expected_first", "expected_last"), + [ + ("Chaplain", "David", "Walden", "David Walden", "David", "Walden"), + ("BSc", "", "Millie Chau", "Millie Chau", "Millie", "Chau"), + ("Frau", "", "Schäfer-Graf", "Schäfer-Graf", "", "Schäfer-Graf"), + ], +) +def test_structured_titles_and_credentials_repair_emptied_given_boundary( # noqa: PLR0913 + normalizer: PersonNameNormalizationService, + first_name: str, + middle_name: str, + last_name: str, + expected: str, + expected_first: str, + expected_last: str, +) -> None: + result = normalizer.normalize_components( + first_name=first_name, + middle_name=middle_name, + last_name=last_name, + ) + + assert result.outcome is PersonNameOutcome.PERSON + assert result.canonical_name is not None + assert result.canonical_name.text == expected + assert result.canonical_name.normalized.given_name == expected_first + assert result.canonical_name.normalized.surname == expected_last + + +@pytest.mark.parametrize( + ("last_name", "expected_first", "expected_middle", "expected_last"), + [ + ("Mifta Rezki", "Mifta", "", "Rezki"), + ("Rajashree Vishnoo Naik", "Rajashree", "Vishnoo", "Naik"), + ("Njarasoa Charlette RANDRIAMALALA", "Njarasoa Charlette", "", "Randriamalala"), + ("Sathya S.", "Sathya", "", "S."), + ], +) +def test_last_only_complete_names_repair_structurally_empty_given_boundary( + normalizer: PersonNameNormalizationService, + last_name: str, + expected_first: str, + expected_middle: str, + expected_last: str, +) -> None: + result = normalizer.normalize_components(last_name=last_name) + + assert result.outcome is PersonNameOutcome.PERSON + assert result.canonical_name is not None + assert result.canonical_name.normalized.given_name == expected_first + assert result.canonical_name.normalized.middle_name == expected_middle + assert result.canonical_name.normalized.surname == expected_last + + +@pytest.mark.parametrize("marker", ["†", "‡", "*", "§"]) +def test_boundary_symbol_and_fused_initial_surname_are_repaired( + normalizer: PersonNameNormalizationService, + marker: str, +) -> None: + result = normalizer.normalize_components(first_name=marker, last_name="W.Tsujita") + + assert result.outcome is PersonNameOutcome.PERSON + assert result.canonical_name is not None + assert result.canonical_name.text == "W. Tsujita" + assert [(token.text, token.reason) for token in result.dropped_tokens] == [(marker, DropReason.CONNECTOR)] + + +def test_last_only_particle_surname_is_not_split( + normalizer: PersonNameNormalizationService, +) -> None: + result = normalizer.normalize_components(last_name="van der Waals") + + assert result.canonical_name is not None + assert result.canonical_name.normalized.given_name == "" + assert result.canonical_name.normalized.surname == "van der Waals" + + +def test_terminal_transliteration_apostrophe_is_preserved( + normalizer: PersonNameNormalizationService, +) -> None: + result = normalizer.normalize_components(first_name="P", middle_name="V", last_name="Bigar'") + + assert result.outcome is PersonNameOutcome.PERSON + assert result.canonical_name is not None + assert result.canonical_name.text == "P V Bigar'" + + +@pytest.mark.parametrize( + ("first_name", "middle_name", "last_name", "expected"), + [ + ("Edilene", "Natália", "Araújo das Graças", "Edilene Natália Araújo das Graças"), + ("Frederico", "Ferreira de", "Oliveira", "Frederico Ferreira de Oliveira"), + ("ROBERT", "C.", "McKEAN", "Robert C. McKean"), + ("O.", "", "LöWENSTEIN", "O. Löwenstein"), + ], +) +def test_source_particles_and_mixed_ocr_case_are_normalized( + normalizer: PersonNameNormalizationService, + first_name: str, + middle_name: str, + last_name: str, + expected: str, +) -> None: + result = normalizer.normalize_components( + first_name=first_name, + middle_name=middle_name, + last_name=last_name, + ) + + assert result.canonical_name is not None + assert result.canonical_name.text == expected + + +@pytest.mark.parametrize( + ("raw_name", "expected"), + [ + ("H.-J. Pompino", "H.-J. Pompino"), + ("Safiye ŞAHİN", "Safiye Şahin"), + ("Mihajlo (Michael) B Jakovljevic", "Mihajlo (Michael) B Jakovljevic"), + ], +) +def test_compound_initial_unicode_case_and_parenthetical_name_are_preserved( + normalizer: PersonNameNormalizationService, + raw_name: str, + expected: str, +) -> None: + result = normalizer.normalize_text(raw_name) + + assert result.outcome is PersonNameOutcome.PERSON + assert result.canonical_name is not None + assert result.canonical_name.text == expected + + +def test_normalize_text_extracts_true_suffix(normalizer: PersonNameNormalizationService) -> None: + result = normalizer.normalize_text("Steve Blando IV") + + assert result.outcome is PersonNameOutcome.PERSON + assert result.canonical_name is not None + assert result.canonical_name.text == "Steve Blando IV" + assert result.canonical_name.normalized.given_name == "Steve" + assert result.canonical_name.normalized.middle_name == "" + assert result.canonical_name.normalized.surname == "Blando" + assert result.canonical_name.normalized.suffix == "IV" + + +def test_comma_suffix_does_not_trigger_family_first_parsing( + normalizer: PersonNameNormalizationService, +) -> None: + result = normalizer.normalize_text("Thomas L. Duvall, Jr.") + + assert result.outcome is PersonNameOutcome.PERSON + assert result.canonical_name is not None + assert result.canonical_name.text == "Thomas L. Duvall Jr." + assert result.canonical_name.normalized.given_name == "Thomas" + assert result.canonical_name.normalized.middle_name == "L." + assert result.canonical_name.normalized.surname == "Duvall" + assert result.canonical_name.normalized.suffix == "Jr." + + +def test_mixed_case_roman_looking_surname_is_not_a_suffix( + normalizer: PersonNameNormalizationService, +) -> None: + result = normalizer.normalize_text("Hiroshi Ii") + + assert result.outcome is PersonNameOutcome.PERSON + assert result.canonical_name is not None + assert result.canonical_name.text == "Hiroshi Ii" + assert result.canonical_name.normalized.surname == "Ii" + assert result.canonical_name.normalized.suffix == "" + + +def test_explicit_comma_parses_family_first_and_preserves_family_particles( + normalizer: PersonNameNormalizationService, +) -> None: + result = normalizer.normalize_text("de la cruz, maría elena") + + assert result.outcome is PersonNameOutcome.PERSON + assert result.canonical_name is not None + assert result.canonical_name.text == "María Elena de la Cruz" + assert result.canonical_name.normalized.given_name == "María" + assert result.canonical_name.normalized.middle_name == "Elena" + assert result.canonical_name.normalized.surname == "de la Cruz" + assert result.canonical_name.normalized.order == ("given", "middle", "surname", "surname", "surname") + assert result.canonical_name.source.order == ("surname", "surname", "surname", "given", "middle") + + +def test_trailing_family_particles_are_kept_with_surname( + normalizer: PersonNameNormalizationService, +) -> None: + result = normalizer.normalize_text("ludwig van der waals") + + assert result.outcome is PersonNameOutcome.PERSON + assert result.canonical_name is not None + assert result.canonical_name.text == "Ludwig van der Waals" + assert result.canonical_name.normalized.middle_name == "" + assert result.canonical_name.normalized.surname == "van der Waals" + + +def test_structured_components_reinfer_roles_after_boundary_tokens_are_dropped( + normalizer: PersonNameNormalizationService, +) -> None: + result = normalizer.normalize_components( + first_name="dr steve", + middle_name="marsh", + last_name="phd", + ) + + assert result.outcome is PersonNameOutcome.PERSON + assert result.canonical_name is not None + assert result.canonical_name.text == "Steve Marsh" + assert result.canonical_name.source.given_name == "dr steve" + assert result.canonical_name.source.middle_name == "marsh" + assert result.canonical_name.source.surname == "phd" + assert result.canonical_name.normalized.given_name == "Steve" + assert result.canonical_name.normalized.middle_name == "" + assert result.canonical_name.normalized.surname == "Marsh" + assert result.canonical_name.normalized.suffix == "" + assert [(token.text, token.source_role, token.reason) for token in result.dropped_tokens] == [ + ("dr", "given", DropReason.TITLE), + ("phd", "surname", DropReason.CREDENTIAL), + ] + + +def test_particle_only_surname_expansion_cannot_cross_explicit_middle( + normalizer: PersonNameNormalizationService, +) -> None: + result = normalizer.normalize_components(first_name="An", middle_name="Van", last_name="Nguyen") + + assert result.canonical_name is not None + assert result.canonical_name.normalized.given_name == "An" + assert result.canonical_name.normalized.middle_name == "Van" + assert result.canonical_name.normalized.surname == "Nguyen" + + +@pytest.mark.parametrize( + ("first_name", "middle_name", "last_name"), + [ + ("Byung", "Chan", "Lee"), + ("Ricardo", "Burciaga", "Castañeda"), + ("Doménica", "Alejandra", "Delgado González"), + ], +) +def test_structured_source_roles_are_preserved_without_mechanical_corruption( + normalizer: PersonNameNormalizationService, + first_name: str, + middle_name: str, + last_name: str, +) -> None: + result = normalizer.normalize_components( + first_name=first_name, + middle_name=middle_name, + last_name=last_name, + ) + + assert result.canonical_name is not None + assert result.canonical_name.normalized.given_name == first_name + assert result.canonical_name.normalized.middle_name == middle_name + assert result.canonical_name.normalized.surname == last_name + + +def test_ambiguous_degree_key_in_surname_case_is_preserved( + normalizer: PersonNameNormalizationService, +) -> None: + result = normalizer.normalize_text("Bao Do") + + assert result.outcome is PersonNameOutcome.PERSON + assert result.canonical_name is not None + assert result.canonical_name.text == "Bao Do" + assert result.canonical_name.normalized.surname == "Do" + assert result.dropped_tokens == () + + +def test_uppercase_leading_credential_is_dropped( + normalizer: PersonNameNormalizationService, +) -> None: + result = normalizer.normalize_text("MD Jane Smith") + + assert result.outcome is PersonNameOutcome.PERSON + assert result.canonical_name is not None + assert result.canonical_name.text == "Jane Smith" + assert [(token.text, token.reason) for token in result.dropped_tokens] == [ + ("MD", DropReason.CREDENTIAL), + ] + + +@pytest.mark.parametrize( + ("raw_name", "expected", "expected_given", "expected_surname"), + [ + ("JOHN MA", "John Ma", "John", "Ma"), + ("MA SMITH", "Ma Smith", "Ma", "Smith"), + ("Smith, Ma", "Ma Smith", "Ma", "Smith"), + ("SMITH, MA", "Ma Smith", "Ma", "Smith"), + ], +) +def test_ambiguous_uppercase_credential_token_is_preserved_when_required_as_name( + normalizer: PersonNameNormalizationService, + raw_name: str, + expected: str, + expected_given: str, + expected_surname: str, +) -> None: + result = normalizer.normalize_text(raw_name) + + assert result.outcome is PersonNameOutcome.PERSON + assert result.canonical_name is not None + assert result.canonical_name.text == expected + assert result.canonical_name.normalized.given_name == expected_given + assert result.canonical_name.normalized.surname == expected_surname + assert result.dropped_tokens == () + + +def test_ambiguous_uppercase_credential_is_still_dropped_from_complete_name( + normalizer: PersonNameNormalizationService, +) -> None: + results = [ + normalizer.normalize_text("JOHN SMITH MA"), + normalizer.normalize_text("John Smith, MA"), + ] + + for result in results: + assert result.outcome is PersonNameOutcome.PERSON + assert result.canonical_name is not None + assert result.canonical_name.text == "John Smith" + assert [(token.text, token.reason) for token in result.dropped_tokens] == [ + ("MA", DropReason.CREDENTIAL), + ] + + +def test_meng_surname_is_preserved_while_meng_degree_is_dropped( + normalizer: PersonNameNormalizationService, +) -> None: + surname = normalizer.normalize_text("Wenhua Meng") + credential = normalizer.normalize_text("John Smith MEng") + + assert surname.canonical_name is not None + assert surname.canonical_name.text == "Wenhua Meng" + assert surname.canonical_name.normalized.surname == "Meng" + assert surname.dropped_tokens == () + assert credential.canonical_name is not None + assert credential.canonical_name.text == "John Smith" + assert [(token.text, token.reason) for token in credential.dropped_tokens] == [ + ("MEng", DropReason.CREDENTIAL), + ] + + +@pytest.mark.parametrize("raw_name", ["Dr Smith", "Ms Smith"]) +def test_short_unambiguous_titles_are_still_dropped( + normalizer: PersonNameNormalizationService, + raw_name: str, +) -> None: + result = normalizer.normalize_text(raw_name) + + assert result.canonical_name is not None + assert result.canonical_name.text == "Smith" + assert [(token.text, token.reason) for token in result.dropped_tokens] == [ + (raw_name.split()[0], DropReason.TITLE), + ] + + +def test_structured_explicit_suffix_supports_single_roman_numeral( + normalizer: PersonNameNormalizationService, +) -> None: + result = normalizer.normalize_components(first_name="john", last_name="smith", suffix="v") + + assert result.outcome is PersonNameOutcome.PERSON + assert result.canonical_name is not None + assert result.canonical_name.text == "John Smith V" + assert result.canonical_name.normalized.suffix == "V" + + +@pytest.mark.parametrize( + ("given_name", "roman_surname"), + [("John", "Vi"), ("John", "Iv"), ("John", "Iii"), ("John", "Vii"), ("Malcolm", "X")], +) +def test_two_token_raw_roman_numeral_is_a_surname_but_explicit_is_a_suffix( + normalizer: PersonNameNormalizationService, + given_name: str, + roman_surname: str, +) -> None: + raw = normalizer.normalize_text(f"{given_name} {roman_surname}") + structured = normalizer.normalize_components(first_name=given_name, suffix=roman_surname) + + assert raw.canonical_name is not None + assert raw.canonical_name.text == f"{given_name} {roman_surname}" + assert raw.canonical_name.normalized.surname == roman_surname + assert raw.canonical_name.normalized.suffix == "" + assert structured.canonical_name is not None + assert structured.canonical_name.text == f"{given_name} {roman_surname.upper()}" + assert structured.canonical_name.normalized.surname == "" + assert structured.canonical_name.normalized.suffix == roman_surname.upper() + + +@pytest.mark.parametrize( + ("raw_name", "expected", "expected_suffix"), + [ + ("Steve Blando IV", "Steve Blando IV", "IV"), + ("John Smith Vi", "John Smith VI", "VI"), + ("Smith, John IV", "John Smith IV", "IV"), + ], +) +def test_raw_roman_suffix_requires_complete_name_context( + normalizer: PersonNameNormalizationService, + raw_name: str, + expected: str, + expected_suffix: str, +) -> None: + result = normalizer.normalize_text(raw_name) + + assert result.canonical_name is not None + assert result.canonical_name.text == expected + assert result.canonical_name.normalized.suffix == expected_suffix + + +def test_structured_missing_surname_is_reinferred_from_one_split_component( + normalizer: PersonNameNormalizationService, +) -> None: + result = normalizer.normalize_components(first_name="Dr. Mary Ann O\u2019Neill", last_name="MS") + + assert result.outcome is PersonNameOutcome.PERSON + assert result.canonical_name is not None + assert result.canonical_name.text == "Mary Ann O'Neill" + assert result.canonical_name.normalized.given_name == "Mary" + assert result.canonical_name.normalized.middle_name == "Ann" + assert result.canonical_name.normalized.surname == "O'Neill" + + +def test_affiliation_digits_are_dropped_without_losing_name_token( + normalizer: PersonNameNormalizationService, +) -> None: + result = normalizer.normalize_text("Anna Spießl1, 4") + + assert result.outcome is PersonNameOutcome.PERSON + assert result.canonical_name is not None + assert result.canonical_name.text == "Anna Spießl" + assert [(token.text, token.source_role, token.reason) for token in result.dropped_tokens] == [ + ("1", "surname", DropReason.AFFILIATION), + ("4", "suffix", DropReason.AFFILIATION), + ] + + +@pytest.mark.parametrize( + ("raw_name", "expected_outcome"), + [ + ("", PersonNameOutcome.INVALID), + ("---", PersonNameOutcome.INVALID), + ("John Smith and Jane Doe", PersonNameOutcome.NON_PERSON), + ("John Smith, Jane Doe", PersonNameOutcome.NON_PERSON), + ("Stanford University", PersonNameOutcome.NON_PERSON), + ], +) +def test_normalize_text_returns_typed_non_person_and_invalid_outcomes( + normalizer: PersonNameNormalizationService, + raw_name: str, + expected_outcome: PersonNameOutcome, +) -> None: + result = normalizer.normalize_text(raw_name) + + assert result.outcome is expected_outcome + assert result.canonical_name is None + assert result.reason + + +def test_result_and_dropped_token_lineage_are_immutable( + normalizer: PersonNameNormalizationService, +) -> None: + result = normalizer.normalize_text("Mr. Steve Blando PhD") + + assert result.canonical_name is not None + reason_attribute = "reason" + text_attribute = "text" + with pytest.raises(FrozenInstanceError): + setattr(result, reason_attribute, "changed") + with pytest.raises(FrozenInstanceError): + setattr(result.dropped_tokens[0], text_attribute, "changed") + + +@pytest.mark.parametrize( + "raw_name", + [ + "Dr. Steve Marsh Blando, Ph.D.", + "de la Cruz, María Elena", + "Thomas L. Duvall, Jr.", + "Anne-Marie O'Connor", + ], +) +def test_component_orders_contain_exactly_one_role_per_token( + normalizer: PersonNameNormalizationService, + raw_name: str, +) -> None: + result = normalizer.normalize_text(raw_name) + + assert result.canonical_name is not None + for components in (result.canonical_name.source, result.canonical_name.normalized): + token_count = sum( + len(tokens) + for tokens in ( + components.given_tokens, + components.middle_tokens, + components.surname_tokens, + components.suffix_tokens, + ) + ) + assert len(components.order) == token_count + + +def test_structured_non_person_and_invalid_results_have_no_canonical_name( + normalizer: PersonNameNormalizationService, +) -> None: + non_person = normalizer.normalize_components(first_name="Stanford", last_name="University") + invalid = normalizer.normalize_components() + + assert non_person.outcome is PersonNameOutcome.NON_PERSON + assert non_person.canonical_name is None + assert invalid.outcome is PersonNameOutcome.INVALID + assert invalid.canonical_name is None + + +@pytest.mark.parametrize( + ("raw_name", "expected"), + [ + ("Vincent El Ghouzzi", "Vincent El Ghouzzi"), + ("Monica Da Costa", "Monica Da Costa"), + ("P. T. d'Orbán", "P. T. d'Orbán"), + ("de la cruz, maría elena", "María Elena de la Cruz"), + ], +) +def test_family_particles_preserve_credible_source_case( + normalizer: PersonNameNormalizationService, + raw_name: str, + expected: str, +) -> None: + result = normalizer.normalize_text(raw_name) + + assert result.canonical_name is not None + assert result.canonical_name.text == expected + + +@pytest.mark.parametrize( + ("raw_name", "expected"), + [ + ("E V Usol'tseva", "E V Usol'tseva"), + ("Wa\u2019el Tuqan", "Wa'el Tuqan"), + ("sean o \u2019 connor", "Sean O'Connor"), + ("Michael F. O''Rourke", "Michael F. O'Rourke"), + ("Carol O\u2019sullivan", "Carol O'Sullivan"), + ("Ruth D'arcy Hart", "Ruth D'Arcy Hart"), + ], +) +def test_apostrophe_normalization_preserves_mixed_case_and_collapses_duplicates( + normalizer: PersonNameNormalizationService, + raw_name: str, + expected: str, +) -> None: + result = normalizer.normalize_text(raw_name) + + assert result.canonical_name is not None + assert result.canonical_name.text == expected + + +@pytest.mark.parametrize( + ("raw_name", "expected"), + [ + ("' J. LEECH", "J. Leech"), + ("Ph. R. Hénon", "Ph. R. Hénon"), + ("Zd. Servit", "Zd. Servit"), + ("A. D\u2018A. BELLAIRS", "A. D'A. Bellairs"), + ], +) +def test_stray_joiners_and_abbreviated_tokens_are_normalized( + normalizer: PersonNameNormalizationService, + raw_name: str, + expected: str, +) -> None: + result = normalizer.normalize_text(raw_name) + + assert result.canonical_name is not None + assert result.canonical_name.text == expected + + +def test_additional_boundary_credentials_and_credential_like_surnames( + normalizer: PersonNameNormalizationService, +) -> None: + mpa = normalizer.normalize_text("Amina Helmi MPA") + spaced_md = normalizer.normalize_text("William A. Horwitz M. D.") + surname = normalizer.normalize_text("E C Mba") + + assert mpa.canonical_name is not None + assert mpa.canonical_name.text == "Amina Helmi" + assert spaced_md.canonical_name is not None + assert spaced_md.canonical_name.text == "William A. Horwitz" + assert surname.canonical_name is not None + assert surname.canonical_name.text == "E C Mba" + + +@pytest.mark.parametrize("credential", ["Ph. D.", "M. Sc."]) +def test_spaced_credentials_are_dropped_in_raw_and_structured_suffix_forms( + normalizer: PersonNameNormalizationService, + credential: str, +) -> None: + raw = normalizer.normalize_text(f"John Smith {credential}") + structured = normalizer.normalize_components(first_name="John", last_name="Smith", suffix=credential) + + for result in (raw, structured): + assert result.outcome is PersonNameOutcome.PERSON + assert result.canonical_name is not None + assert result.canonical_name.text == "John Smith" + assert all(token.reason is DropReason.CREDENTIAL for token in result.dropped_tokens) + + +@pytest.mark.parametrize( + ("raw_name", "expected_suffix"), + [("McKinley Glover Iv", "IV"), ("N David Yanez Iii", "III"), ("Hiroshi Ii", "")], +) +def test_mixed_case_long_roman_suffixes_are_unambiguous( + normalizer: PersonNameNormalizationService, + raw_name: str, + expected_suffix: str, +) -> None: + result = normalizer.normalize_text(raw_name) + + assert result.canonical_name is not None + assert result.canonical_name.normalized.suffix == expected_suffix + + +def test_stacked_academic_titles_and_parenthetical_duplicate_are_removed( + normalizer: PersonNameNormalizationService, +) -> None: + titled = normalizer.normalize_text("Univ.-Prof. Dr. med. Prof. honoraire Dr. h.c. C. C. Zouboulis") + parenthetical = normalizer.normalize_text("Alan (Alan B.) Cantor") + + assert titled.canonical_name is not None + assert titled.canonical_name.text == "C. C. Zouboulis" + assert parenthetical.canonical_name is not None + assert parenthetical.canonical_name.text == "Alan B. Cantor" + + +def test_trailing_affiliation_is_removed_after_complete_person_name( + normalizer: PersonNameNormalizationService, +) -> None: + result = normalizer.normalize_text("Rachel Webster University of New South Wales") + + assert result.outcome is PersonNameOutcome.PERSON + assert result.canonical_name is not None + assert result.canonical_name.text == "Rachel Webster" + + +def test_two_token_particle_like_given_name_is_not_duplicated( + normalizer: PersonNameNormalizationService, +) -> None: + result = normalizer.normalize_text("Ben Sherwood") + + assert result.canonical_name is not None + assert result.canonical_name.text == "Ben Sherwood" + assert result.canonical_name.normalized.given_name == "Ben" + assert result.canonical_name.normalized.surname == "Sherwood" + + +def test_uppercase_initial_clusters_remain_uppercase( + normalizer: PersonNameNormalizationService, +) -> None: + result = normalizer.normalize_text("Gordon CS Smith") + + assert result.canonical_name is not None + assert result.canonical_name.text == "Gordon CS Smith" + + +@pytest.mark.parametrize( + ("raw_name", "expected"), + [ + ("N.p Sunil-Chandra", "N.P Sunil-Chandra"), + ("G.Y Minuk", "G.Y Minuk"), + ("K.D.D.I Kodithuwakku", "K.D.D.I Kodithuwakku"), + ("Alekseeva M.Yu. Alekseeva", "Alekseeva M.Yu. Alekseeva"), + ("P.Sh. Ibragimov", "P.Sh. Ibragimov"), + ("MM. Cunningham", "MM. Cunningham"), + ], +) +def test_period_policy_distinguishes_initial_clusters_from_transliteration_abbreviations( + normalizer: PersonNameNormalizationService, + raw_name: str, + expected: str, +) -> None: + result = normalizer.normalize_text(raw_name) + + assert result.canonical_name is not None + assert result.canonical_name.text == expected + + +@pytest.mark.parametrize( + ("raw_name", "expected"), + [ + ("Dr.Wenjun Zhang", "Wenjun Zhang"), + ("Mrs.E. Sumathi", "E. Sumathi"), + ("Dr.AARCHA S S", "Aarcha S S"), + ("PD Dr. M. Mengel", "M. Mengel"), + ], +) +def test_attached_and_stacked_titles_are_removed_without_dropping_the_name( + normalizer: PersonNameNormalizationService, + raw_name: str, + expected: str, +) -> None: + result = normalizer.normalize_text(raw_name) + + assert result.canonical_name is not None + assert result.canonical_name.text == expected + assert all(token.reason is DropReason.TITLE for token in result.dropped_tokens) + expected_first_key = "".join(character.casefold() for character in expected.split()[0] if character.isalnum()) + source_first_count = sum( + "".join(character.casefold() for character in token if character.isalnum()) == expected_first_key + for token in result.canonical_name.source.given_tokens + ) + assert source_first_count == 1 + + +def test_period_context_preserves_md_given_abbreviation_and_ms_initials( + normalizer: PersonNameNormalizationService, +) -> None: + md = normalizer.normalize_text("Md. Abu Bakar Siddiq") + initials = normalizer.normalize_text("M.S. Crouse") + credential = normalizer.normalize_text("Jaume Bosch M.D.") + + assert md.canonical_name is not None + assert md.canonical_name.text == "Md. Abu Bakar Siddiq" + assert initials.canonical_name is not None + assert initials.canonical_name.text == "M.S. Crouse" + assert credential.canonical_name is not None + assert credential.canonical_name.text == "Jaume Bosch" + + +def test_terminal_and_standalone_sentence_periods_are_removed( + normalizer: PersonNameNormalizationService, +) -> None: + terminal = normalizer.normalize_text("Subramanian. A") + standalone = normalizer.normalize_text("A.Kalaikannan .") + + assert terminal.canonical_name is not None + assert terminal.canonical_name.text == "Subramanian A" + assert standalone.canonical_name is not None + assert standalone.canonical_name.text == "A.Kalaikannan" + + +@pytest.mark.parametrize( + ("raw_name", "expected"), + [ + ("DAVID NG", "David Ng"), + ("JUAN DE LA CRUZ", "Juan de la Cruz"), + ], +) +def test_uppercase_two_letter_surnames_and_particles_use_name_case( + normalizer: PersonNameNormalizationService, + raw_name: str, + expected: str, +) -> None: + result = normalizer.normalize_text(raw_name) + + assert result.canonical_name is not None + assert result.canonical_name.text == expected + + +@pytest.mark.parametrize( + ("raw_name", "expected_middle", "expected_surname"), + [ + ("Juan J Llibre Rodriguez", "J", "Llibre Rodriguez"), + ("Carlos A. Henríquez Q.", "A.", "Henríquez Q."), + ("Landys A. Lopez Quezada", "A.", "Lopez Quezada"), + ], +) +def test_initial_followed_by_full_token_preserves_two_token_family_name( + normalizer: PersonNameNormalizationService, + raw_name: str, + expected_middle: str, + expected_surname: str, +) -> None: + result = normalizer.normalize_text(raw_name) + + assert result.canonical_name is not None + assert result.canonical_name.normalized.middle_name == expected_middle + assert result.canonical_name.normalized.surname == expected_surname + + +def test_ordinary_three_token_name_keeps_middle_name( + normalizer: PersonNameNormalizationService, +) -> None: + result = normalizer.normalize_text("John Michael Smith") + + assert result.canonical_name is not None + assert result.canonical_name.normalized.middle_name == "Michael" + assert result.canonical_name.normalized.surname == "Smith" + + +def test_structured_middle_drops_only_surname_copy_with_lowercase_marker( + normalizer: PersonNameNormalizationService, +) -> None: + result = normalizer.normalize_components( + first_name="Mery", + middle_name="Luz Rojas Aire z", + last_name="Rojas Aire", + ) + + assert result.outcome is PersonNameOutcome.PERSON + assert result.canonical_name is not None + assert result.canonical_name.text == "Mery Luz Rojas Aire" + assert result.canonical_name.source.middle_name == "Luz Rojas Aire z" + assert result.canonical_name.normalized.given_name == "Mery" + assert result.canonical_name.normalized.middle_name == "Luz" + assert result.canonical_name.normalized.surname == "Rojas Aire" + assert [(token.text, token.source_role, token.reason) for token in result.dropped_tokens] == [ + ("Rojas", "middle", DropReason.DUPLICATE), + ("Aire", "middle", DropReason.DUPLICATE), + ("z", "middle", DropReason.CONNECTOR), + ] + + +@pytest.mark.parametrize( + ("middle_name", "last_name", "expected_middle"), + [ + ("Smith", "Smith", "Smith"), + ("Luz Rojas Aire", "Rojas Aire", "Luz Rojas Aire"), + ("Luz Rojas Aire Z", "Rojas Aire", "Luz Rojas Aire Z"), + ("Luz Rojas Air z", "Rojas Aire", "Luz Rojas Air Z"), + ], +) +def test_structured_middle_does_not_broadly_deduplicate_surnames( + normalizer: PersonNameNormalizationService, + middle_name: str, + last_name: str, + expected_middle: str, +) -> None: + result = normalizer.normalize_components( + first_name="Mery", + middle_name=middle_name, + last_name=last_name, + ) + + assert result.canonical_name is not None + assert result.canonical_name.normalized.middle_name == expected_middle + assert result.canonical_name.normalized.surname == last_name + + +def test_exact_leading_ma_abbreviation_is_preserved_before_initial_and_surname( + normalizer: PersonNameNormalizationService, +) -> None: + results = [ + normalizer.normalize_text("Ma. E. Zayas"), + normalizer.normalize_components(first_name="Ma.", middle_name="E.", last_name="Zayas"), + ] + + for result in results: + assert result.outcome is PersonNameOutcome.PERSON + assert result.canonical_name is not None + assert result.canonical_name.text == "Ma. E. Zayas" + assert result.canonical_name.normalized.given_name == "Ma." + assert result.canonical_name.normalized.middle_name == "E." + assert result.canonical_name.normalized.surname == "Zayas" + assert result.dropped_tokens == () + + +@pytest.mark.parametrize("raw_name", ["M.A. E. Zayas", "Jane Smith M.A."]) +def test_ma_credential_forms_are_still_dropped( + normalizer: PersonNameNormalizationService, + raw_name: str, +) -> None: + result = normalizer.normalize_text(raw_name) + + assert result.canonical_name is not None + assert "M.A." not in result.canonical_name.text + assert [(token.text, token.reason) for token in result.dropped_tokens] == [ + ("M.A.", DropReason.CREDENTIAL), + ] + + +def test_lowercase_marker_fused_to_leading_initial_is_split_with_lineage( + normalizer: PersonNameNormalizationService, +) -> None: + results = [ + normalizer.normalize_text("tE. L. Winkelman"), + normalizer.normalize_components(first_name="tE.", middle_name="L.", last_name="Winkelman"), + ] + + for result in results: + assert result.outcome is PersonNameOutcome.PERSON + assert result.canonical_name is not None + assert result.canonical_name.text == "E. L. Winkelman" + assert result.canonical_name.source.given_name == "tE." + assert result.canonical_name.normalized.given_name == "E." + assert result.canonical_name.normalized.middle_name == "L." + assert result.canonical_name.normalized.surname == "Winkelman" + assert [(token.text, token.source_role, token.reason) for token in result.dropped_tokens] == [ + ("t", "given", DropReason.CONNECTOR), + ] + + +@pytest.mark.parametrize("raw_name", ["tE. Louis Winkelman", "eBay L. Smith", "JoAnn L. Smith"]) +def test_leading_mixed_case_names_are_not_generically_split( + normalizer: PersonNameNormalizationService, + raw_name: str, +) -> None: + result = normalizer.normalize_text(raw_name) + + assert result.canonical_name is not None + assert result.canonical_name.text == raw_name + assert result.dropped_tokens == () + + +@pytest.mark.parametrize( + ("raw_name", "expected"), + [ + ("Dr Li", "Li"), + ("Doctor Li", "Li"), + ("Pastor John Smith", "John Smith"), + ("Frau Ng", "Ng"), + ("Rabbi Ng", "Ng"), + ("Lord Ng", "Ng"), + ("Pastor Ng", "Ng"), + ], +) +def test_unambiguous_title_contexts_are_still_dropped( + normalizer: PersonNameNormalizationService, + raw_name: str, + expected: str, +) -> None: + result = normalizer.normalize_text(raw_name) + + assert result.canonical_name is not None + assert result.canonical_name.text == expected + assert len(result.dropped_tokens) == 1 + assert result.dropped_tokens[0].reason is DropReason.TITLE + + +def test_separated_compound_initials_join_only_at_visible_initial_boundary( + normalizer: PersonNameNormalizationService, +) -> None: + raw = normalizer.normalize_text("H. -J. Schneider") + structured = normalizer.normalize_components(first_name="H.", middle_name="-J.", last_name="Schneider") + + for result in (raw, structured): + assert result.outcome is PersonNameOutcome.PERSON + assert result.canonical_name is not None + assert result.canonical_name.text == "H.-J. Schneider" + assert result.canonical_name.normalized.given_name == "H.-J." + assert result.canonical_name.normalized.middle_name == "" + assert result.canonical_name.normalized.surname == "Schneider" + + assert structured.canonical_name is not None + assert structured.canonical_name.source.given_name == "H." + assert structured.canonical_name.source.middle_name == "-J." + + +def test_compound_initial_join_does_not_change_normal_initial_order_or_words( + normalizer: PersonNameNormalizationService, +) -> None: + normal = normalizer.normalize_text("H. J. Schneider") + word = normalizer.normalize_components(first_name="H.", middle_name="-Jean", last_name="Schneider") + + assert normal.canonical_name is not None + assert normal.canonical_name.text == "H. J. Schneider" + assert normal.canonical_name.normalized.middle_name == "J." + assert word.outcome is PersonNameOutcome.INVALID + + +def test_leading_superscript_affiliation_marker_is_removed_with_lineage( + normalizer: PersonNameNormalizationService, +) -> None: + raw = normalizer.normalize_text("\u00b9Matias Julyus") + structured = normalizer.normalize_components(first_name="\u00b9Matias", last_name="Julyus") + + for result in (raw, structured): + assert result.outcome is PersonNameOutcome.PERSON + assert result.canonical_name is not None + assert result.canonical_name.text == "Matias Julyus" + assert result.canonical_name.source.given_name == "\u00b9Matias" + assert result.canonical_name.normalized.given_name == "Matias" + assert result.canonical_name.normalized.surname == "Julyus" + assert [(token.text, token.source_role, token.reason) for token in result.dropped_tokens] == [ + ("\u00b9", "given", DropReason.AFFILIATION), + ] + + +@pytest.mark.parametrize("raw_name", ["1Matias Julyus", "Ma\u00b9tias Julyus"]) +def test_ordinary_digits_and_internal_superscripts_are_not_affiliation_prefixes( + normalizer: PersonNameNormalizationService, + raw_name: str, +) -> None: + result = normalizer.normalize_text(raw_name) + + assert result.outcome is PersonNameOutcome.INVALID + + +@pytest.mark.parametrize( + ("first_name", "middle_name", "last_name", "expected"), + [ + ("\u00c9va", "d. H.", "Alm\u00e1si", "\u00c9va d. H. Alm\u00e1si"), + ("L.", "M. b.", "Hoskins", "L. M. b. Hoskins"), + ("F.", "E. Soares e", "Silva", "F. E. Soares e Silva"), + ("Yuldosheva", "Zulfizar Sobir", "kizi", "Yuldosheva Zulfizar Sobir kizi"), + ("Aysel", "Mammad", "qizi", "Aysel Mammad qizi"), + ], +) +def test_lowercase_relational_tokens_preserve_source_case_by_role( + normalizer: PersonNameNormalizationService, + first_name: str, + middle_name: str, + last_name: str, + expected: str, +) -> None: + result = normalizer.normalize_components( + first_name=first_name, + middle_name=middle_name, + last_name=last_name, + ) + + assert result.canonical_name is not None + assert result.canonical_name.text == expected + + +@pytest.mark.parametrize( + ("raw_name", "expected"), + [ + ("d. Smith", "D. Smith"), + ("kizi Smith", "Kizi Smith"), + ("McDONALD Smith", "McDonald Smith"), + ("McDonald Smith", "McDonald Smith"), + ("FitzGerald Smith", "FitzGerald Smith"), + ], +) +def test_relational_case_rule_does_not_preserve_given_or_ocr_casing( + normalizer: PersonNameNormalizationService, + raw_name: str, + expected: str, +) -> None: + result = normalizer.normalize_text(raw_name) + + assert result.canonical_name is not None + assert result.canonical_name.text == expected + + +def test_known_alphabetic_name_abbreviation_preserves_meaningful_period( + normalizer: PersonNameNormalizationService, +) -> None: + raw = normalizer.normalize_text("Most. Sumaiya Khatun Kali") + structured = normalizer.normalize_components(first_name="Most.", middle_name="Sumaiya Khatun", last_name="Kali") + + for result in (raw, structured): + assert result.canonical_name is not None + assert result.canonical_name.text == "Most. Sumaiya Khatun Kali" + assert result.canonical_name.normalized.given_name == "Most." + + +@pytest.mark.parametrize(("raw_name", "expected"), [("Mark. Smith", "Mark Smith"), ("Subramanian. A", "Subramanian A")]) +def test_sentence_like_periods_are_not_preserved_as_abbreviations( + normalizer: PersonNameNormalizationService, + raw_name: str, + expected: str, +) -> None: + result = normalizer.normalize_text(raw_name) + + assert result.canonical_name is not None + assert result.canonical_name.text == expected + + +def test_packed_surname_first_with_two_trailing_initials_is_reordered( + normalizer: PersonNameNormalizationService, +) -> None: + raw = normalizer.normalize_text("Masterov R. A.") + structured = normalizer.normalize_components(last_name="Masterov R. A.") + + for result in (raw, structured): + assert result.canonical_name is not None + assert result.canonical_name.text == "R. A. Masterov" + assert result.canonical_name.normalized.given_name == "R." + assert result.canonical_name.normalized.middle_name == "A." + assert result.canonical_name.normalized.surname == "Masterov" + + assert raw.canonical_name is not None + assert raw.canonical_name.source.order == ("surname", "given", "middle") + + +def test_packed_surname_first_gate_preserves_normal_order_and_source_last_floor( + normalizer: PersonNameNormalizationService, +) -> None: + normal = normalizer.normalize_text("R. A. Masterov") + one_initial = normalizer.normalize_text("Masterov R.") + source_last = normalizer.normalize_components(first_name="John", last_name="Masterov R. A.") + + assert normal.canonical_name is not None + assert normal.canonical_name.text == "R. A. Masterov" + assert normal.canonical_name.normalized.surname == "Masterov" + assert one_initial.canonical_name is not None + assert one_initial.canonical_name.text == "Masterov R." + assert one_initial.canonical_name.normalized.given_name == "Masterov" + assert source_last.canonical_name is not None + assert source_last.canonical_name.normalized.given_name == "John" + assert source_last.canonical_name.normalized.surname == "Masterov R. A." + + +@pytest.mark.parametrize( + ("raw_name", "expected_first", "expected_middle", "expected_last"), + [ + ("M. Calder\u00f3n de la Barca S\u00e1nchez", "M.", "", "Calder\u00f3n de la Barca S\u00e1nchez"), + ("Roberto Jos\u00e9 Carvalho da Silva", "Roberto", "Jos\u00e9", "Carvalho da Silva"), + ("Carvalho da Silva Roberto Jos\u00e9", "Roberto", "Jos\u00e9", "Carvalho da Silva"), + ], +) +def test_strong_particle_spans_expand_compound_surname_floor( + normalizer: PersonNameNormalizationService, + raw_name: str, + expected_first: str, + expected_middle: str, + expected_last: str, +) -> None: + result = normalizer.normalize_text(raw_name) + + assert result.canonical_name is not None + assert result.canonical_name.normalized.given_name == expected_first + assert result.canonical_name.normalized.middle_name == expected_middle + assert result.canonical_name.normalized.surname == expected_last + + +def test_structured_strong_particle_repairs_cover_floor_and_mechanical_shift( + normalizer: PersonNameNormalizationService, +) -> None: + calderon = normalizer.normalize_components( + first_name="M.", + middle_name="Calder\u00f3n de la Barca", + last_name="S\u00e1nchez", + ) + carvalho = normalizer.normalize_components( + first_name="Carvalho", + middle_name="da Silva Roberto", + last_name="Jos\u00e9", + ) + + assert calderon.canonical_name is not None + assert calderon.canonical_name.text == "M. Calder\u00f3n de la Barca S\u00e1nchez" + assert calderon.canonical_name.normalized.middle_name == "" + assert calderon.canonical_name.normalized.surname == "Calder\u00f3n de la Barca S\u00e1nchez" + assert carvalho.canonical_name is not None + assert carvalho.canonical_name.text == "Roberto Jos\u00e9 Carvalho da Silva" + assert carvalho.canonical_name.normalized.given_name == "Roberto" + assert carvalho.canonical_name.normalized.middle_name == "Jos\u00e9" + assert carvalho.canonical_name.normalized.surname == "Carvalho da Silva" + + +def test_particle_floor_does_not_override_weak_context_or_source_last( + normalizer: PersonNameNormalizationService, +) -> None: + weak = normalizer.normalize_components(first_name="John", middle_name="Michael de la", last_name="Smith") + incomplete_shift = normalizer.normalize_components(first_name="Carvalho", middle_name="da Silva", last_name="Jos\u00e9") + title_case_surname = normalizer.normalize_text("H. K. Das Gupta") + + assert weak.canonical_name is not None + assert weak.canonical_name.normalized.given_name == "John" + assert weak.canonical_name.normalized.middle_name == "Michael de la" + assert weak.canonical_name.normalized.surname == "Smith" + assert incomplete_shift.canonical_name is not None + assert incomplete_shift.canonical_name.normalized.given_name == "Carvalho" + assert incomplete_shift.canonical_name.normalized.middle_name == "da Silva" + assert incomplete_shift.canonical_name.normalized.surname == "Jos\u00e9" + assert title_case_surname.canonical_name is not None + assert title_case_surname.canonical_name.normalized.middle_name == "K." + assert title_case_surname.canonical_name.normalized.surname == "Das Gupta" diff --git a/tests/test_regression_proposals.py b/tests/test_regression_proposals.py index 0d1417e..7c65c6a 100644 --- a/tests/test_regression_proposals.py +++ b/tests/test_regression_proposals.py @@ -664,6 +664,24 @@ def test_aligned_bilingual_pairs_use_han_identity(detector): assert roman_first_given_first.parsed_original_order.order == ["given", "surname"] +def test_aligned_bilingual_polyphonic_surname_precedes_roman_ethnicity_rejection(detector): + raw_name = "Zhexu \u54f2\u65ed Shan \u5355" + normalized = detector._normalizer.apply(raw_name) + pairs = detector._normalizer.aligned_bilingual_pairs(normalized) + + assert pairs is not None + assert [pair.han_pinyin for pair in pairs] == [("zhe", "xu"), ("shan",)] + assert detector._normalizer.classify_script_representation(normalized) == "bilingual_aligned" + + result = detector.normalize_name(raw_name) + + assert result.success + assert result.result == "Zhe-Xu Shan" + assert result.parsed.surname == "Shan" + assert result.parsed.given_name == "Zhe-Xu" + assert result.parsed_original_order.order == ["given", "surname"] + + @pytest.mark.parametrize("lu_token", ["Lu", "L\u00fc"]) def test_aligned_bilingual_lu_alias_matches_lv_pinyin(detector, lu_token): result = detector.normalize_name(f"{lu_token} \u5415 Wei \u4f1f") diff --git a/tests/test_timo_v2_interface.py b/tests/test_timo_v2_interface.py new file mode 100644 index 0000000..8504a81 --- /dev/null +++ b/tests/test_timo_v2_interface.py @@ -0,0 +1,194 @@ +"""Versioning and canonical-name coverage for TIMO v2.""" + +import hashlib +import json +from pathlib import Path + +import pytest + +from sinonym.timo.interface import ( + BatchPrediction, + BatchSummary, + Instance, + PPRoutedPrediction, + Prediction, + PredictionV2, + Predictor, + PredictorConfig, + PredictorV2, + RoutedPaperPrediction, + RoutedPaperPredictionV2, + RoutedPrediction, + RoutingInstance, + RoutingPredictorV2, + TimoModel, +) + +V1_SCHEMA_FINGERPRINTS = { + "Prediction": "32ec52f7cae59d443bd8c221db6a7fc8f06dd5658978e54d264f919820807775", + "BatchPrediction": "3f3ccaa1fc9185296f6bb0f68acbe087da4aa900326eedc325e4505dfc3b15c2", + "BatchSummary": "01640c80b212f7f58dc3f5c0d83c876c60864dad88ec77c0c4ac326c0fee0f97", + "RoutedPrediction": "859a0b5c5f57061ee6850cebbc623667ae3a6b6e4e0d5b952f8d4774da7f5f22", + "PPRoutedPrediction": "ed2febe81a511784ece36c52a17f57dbbbe4b57ff64de9c68c205e47a348debf", + "RoutedPaperPrediction": "b45280f7abb5af523f30d129907e4ff09e0c2932074a23de5d4deca73bcf8738", +} + + +@pytest.fixture(scope="module") +def predictor_v1() -> Predictor: + return Predictor(config=PredictorConfig(parallel="never"), artifacts_dir=".") + + +@pytest.fixture(scope="module") +def predictor_v2() -> PredictorV2: + return PredictorV2(config=PredictorConfig(parallel="never"), artifacts_dir=".") + + +def _schema_fingerprint(model: type[TimoModel]) -> str: + payload = json.dumps(model.schema(), sort_keys=True, separators=(",", ":")) + return hashlib.sha256(payload.encode()).hexdigest() + + +def _without_canonical(value): + if isinstance(value, list): + return [_without_canonical(item) for item in value] + if isinstance(value, dict): + return {key: _without_canonical(item) for key, item in value.items() if key != "canonical_name"} + return value + + +def _assert_steve_marsh(prediction: PredictionV2) -> None: + assert not prediction.success + assert prediction.given_name is None + assert prediction.surname is None + assert prediction.canonical_name is not None + assert prediction.canonical_name.text == "Steve Marsh" + assert prediction.canonical_name.normalized.given_name == "Steve" + assert prediction.canonical_name.normalized.middle_name == "" + assert prediction.canonical_name.normalized.surname == "Marsh" + assert prediction.canonical_name.normalized.suffix == "" + + +def test_v1_schema_fingerprints_match_main_before_v2() -> None: + """Adding v2 types must not mutate any existing TIMO response schema.""" + models = (Prediction, BatchPrediction, BatchSummary, RoutedPrediction, PPRoutedPrediction, RoutedPaperPrediction) + + assert {model.__name__: _schema_fingerprint(model) for model in models} == V1_SCHEMA_FINGERPRINTS + + +def test_v1_dict_excludes_canonical_name(predictor_v1: Predictor) -> None: + (prediction,) = predictor_v1.predict_batch([Instance(name="Dr. Steve Marsh PhD")]) + + assert prediction.dict() == { + "success": False, + "error_message": "no Chinese evidence found", + "given_name": None, + "surname": None, + "middle_name": None, + "confidence": 0.0, + "format_pattern": { + "dominant_format": "mixed", + "confidence": 0.0, + "decision_confidence": 0.0, + "surname_first_count": 0, + "given_first_count": 0, + "total_count": 0, + "voting_count": 0, + "vote_margin_count": 0, + "vote_margin": 0.0, + "threshold_met": False, + }, + } + + +def test_v2_flat_prediction_surfaces_non_chinese_components_and_suffix(predictor_v2: PredictorV2) -> None: + steve_marsh, steve_blando = predictor_v2.predict_batch( + [Instance(name="Dr. Steve Marsh PhD"), Instance(name="Steve Blando IV")], + ) + + _assert_steve_marsh(steve_marsh) + assert steve_blando.canonical_name is not None + assert steve_blando.canonical_name.text == "Steve Blando IV" + assert steve_blando.canonical_name.normalized.given_name == "Steve" + assert steve_blando.canonical_name.normalized.surname == "Blando" + assert steve_blando.canonical_name.normalized.suffix == "IV" + assert steve_blando.canonical_name.normalized.suffix_tokens == ["IV"] + assert steve_blando.canonical_name.normalized.order == ["given", "surname", "suffix"] + + +def test_v2_batch_and_process_helpers_retain_canonical_name(predictor_v2: PredictorV2) -> None: + name = "Dr. Steve Marsh PhD" + + _assert_steve_marsh(predictor_v2.process_name_batch([name])[0]) + _assert_steve_marsh(predictor_v2.process_name_batches([[name]], parallel="never")[0][0]) + _assert_steve_marsh(predictor_v2.analyze_name_batch([name]).results[0]) + _assert_steve_marsh(predictor_v2.score_name_batch([name]).results[0]) + + +def test_v2_multiprocess_helper_retains_canonical_name(predictor_v2: PredictorV2) -> None: + (prediction,) = predictor_v2.process_name_batch_multiprocess( + ["Dr. Steve Marsh PhD"], + max_workers=1, + chunk_size=1, + ) + + _assert_steve_marsh(prediction) + + +def test_v2_pp_only_routing_retains_canonical_without_changing_decision( + predictor_v1: Predictor, + predictor_v2: PredictorV2, +) -> None: + names = ["Steve Blando IV", "Yue Lin", "Wei Wang"] + v1 = predictor_v1.route_pp(names) + v2 = predictor_v2.route_pp(names) + + assert [_without_canonical(result.dict()) for result in v2] == [result.dict() for result in v1] + assert v2[0].canonical_name is not None + assert v2[0].canonical_name.normalized.suffix == "IV" + assert v2[0].pp.canonical_name == v2[0].canonical_name + + +def test_v2_pp_vys_routing_retains_canonical_without_changing_decision( + predictor_v1: Predictor, + predictor_v2: PredictorV2, +) -> None: + pp_names = ["Steve Blando IV", "Yue Lin"] + pool = [*pp_names, "Wei Wang", "Jun Zhao", "Hui Li", "Tao Sun"] + v1 = predictor_v1.route_pp_vys(pp_names, pool) + v2 = predictor_v2.route_pp_vys(pp_names, pool) + + assert [_without_canonical(result.dict()) for result in v2] == [result.dict() for result in v1] + assert v2[0].canonical_name is not None + assert v2[0].canonical_name.normalized.suffix == "IV" + assert v2[0].pp.canonical_name is not None + assert v2[0].vys is not None + assert v2[0].vys.canonical_name is not None + + +def test_routing_predictor_v2_is_one_to_one_and_round_trips() -> None: + predictor = RoutingPredictorV2(config=PredictorConfig(parallel="never"), artifacts_dir=".") + instances = [ + RoutingInstance(pp_names=["Steve Blando IV"]), + RoutingInstance(pp_names=["Yue Lin"], vys_pool_names=["Yue Lin", "Wei Wang", "Jun Zhao"]), + RoutingInstance(pp_names=[]), + ] + + results = predictor.predict_batch(instances) + + assert len(results) == len(instances) + assert [len(result.authors) for result in results] == [1, 1, 0] + assert results[0].authors[0].canonical_name is not None + assert results[0].authors[0].canonical_name.normalized.suffix == "IV" + assert [RoutedPaperPredictionV2(**result.dict()) for result in results] == results + + +def test_timo_config_exposes_separate_v2_variants() -> None: + config = Path("sinonym/timo/config.yaml").read_text(encoding="utf-8") + + assert "sinonym_v2:" in config + assert "prediction: sinonym.timo.interface.PredictionV2" in config + assert "predictor: sinonym.timo.interface.PredictorV2" in config + assert "sinonym_routing_v2:" in config + assert "prediction: sinonym.timo.interface.RoutedPaperPredictionV2" in config + assert "predictor: sinonym.timo.interface.RoutingPredictorV2" in config diff --git a/uv.lock b/uv.lock index 918136d..d1545b3 100644 --- a/uv.lock +++ b/uv.lock @@ -690,7 +690,7 @@ wheels = [ [[package]] name = "sinonym" -version = "0.3.0" +version = "0.4.0" source = { editable = "." } dependencies = [ { name = "joblib" }, From a035ea281df37940e9a441e0caa9775d54326d28 Mon Sep 17 00:00:00 2001 From: sergeyf Date: Mon, 13 Jul 2026 07:45:26 -0700 Subject: [PATCH 2/2] Double name normalization throughput --- sinonym/detector.py | 168 ++++++++++++------ sinonym/services/east_asian_name_order.py | 21 +-- sinonym/services/ethnicity.py | 23 ++- sinonym/services/name_lookup.py | 13 +- sinonym/services/non_person.py | 6 +- sinonym/services/normalization.py | 33 +++- sinonym/services/parsing.py | 8 +- sinonym/services/person_name_normalization.py | 34 ++++ sinonym/text_processing/text_preprocessor.py | 4 + tests/test_canonical_name_integration.py | 22 +++ ...t_chinese_classification_evidence_gates.py | 11 ++ tests/test_person_name_normalization.py | 29 +++ 12 files changed, 296 insertions(+), 76 deletions(-) diff --git a/sinonym/detector.py b/sinonym/detector.py index 890869e..f40e776 100644 --- a/sinonym/detector.py +++ b/sinonym/detector.py @@ -220,6 +220,7 @@ BILINGUAL_SURNAME_STRENGTH_RATIO_MIN = 5.0 BILINGUAL_ROMAN_HAN_SOURCE_ORDER_RATIO_MAX = 12.0 BILINGUAL_ENDPOINT_PAIR_COUNT = 2 +TWO_TOKEN_NAME_COUNT = 2 SPACED_ALL_CHINESE_GROUP_COUNT = 2 THREE_CHARACTER_ALL_CHINESE_TOKEN_COUNT = 3 SPACED_HAN_PREFIX_SURNAME_RATIO_MIN = 5.0 @@ -880,7 +881,7 @@ def _normalize_chinese_name(self, raw_name: str) -> ParseResult: return ParseResult.failure(f"needs at least {self._config.min_tokens_required} Roman tokens") # Check if this is an all-Chinese input first - is_all_chinese = self._normalizer._text_preprocessor.is_all_chinese_input(raw_name) + is_all_chinese = not raw_name.isascii() and self._normalizer._text_preprocessor.is_all_chinese_input(raw_name) # Exact alternating Roman/Han alignment is stronger evidence than the # Roman-only ethnicity gate, especially for polyphonic Han surnames. @@ -1057,46 +1058,66 @@ def _normalize_chinese_name(self, raw_name: str) -> ParseResult: original_tokens = list(normalized_input.roman_tokens) best_candidate = None - for order in (normalized_input.roman_tokens, normalized_input.roman_tokens[::-1]): - order_tokens = list(order) - parse_result = self._parsing_service.parse_name_order_tokens( - order_tokens, - normalized_input.norm_map, - normalized_input.compound_metadata, - ) - if parse_result is None: - continue - - surname_tokens, given_tokens, original_compound_surname = parse_result - score = self._parsing_service.calculate_parse_score( - surname_tokens, - given_tokens, + # For two tokens the parser already evaluates both endpoint surname + # candidates. Reversing repeats the same candidate set; uncertain + # fallback parses and parenthetical-order hints retain the full path. + if len(original_tokens) == TWO_TOKEN_NAME_COUNT and not normalized_input.surname_first_parenthetical_hint: + direct_parse = self._parsing_service._best_parse_tokens( original_tokens, normalized_input.norm_map, - is_all_chinese=False, - original_compound_format=original_compound_surname, - surname_first_parenthetical_hint=normalized_input.surname_first_parenthetical_hint, + normalized_input.compound_metadata, ) - used_original = order_tokens == original_tokens - - candidate = { - "surname_tokens": surname_tokens, - "given_tokens": given_tokens, - "score": score, - "order_tokens": order_tokens, - "used_original": used_original, - } - - if ( - best_candidate is None - or candidate["score"] > best_candidate["score"] - or ( - candidate["score"] == best_candidate["score"] - and candidate["used_original"] - and not best_candidate["used_original"] + if direct_parse is not None: + surname_tokens, given_tokens, _original_compound_surname = direct_parse + best_candidate = { + "surname_tokens": surname_tokens, + "given_tokens": given_tokens, + "score": 0.0, + "order_tokens": original_tokens, + "used_original": True, + } + + if best_candidate is None: + for order in (normalized_input.roman_tokens, normalized_input.roman_tokens[::-1]): + order_tokens = list(order) + parse_result = self._parsing_service.parse_name_order_tokens( + order_tokens, + normalized_input.norm_map, + normalized_input.compound_metadata, + ) + if parse_result is None: + continue + + surname_tokens, given_tokens, original_compound_surname = parse_result + score = self._parsing_service.calculate_parse_score( + surname_tokens, + given_tokens, + original_tokens, + normalized_input.norm_map, + is_all_chinese=False, + original_compound_format=original_compound_surname, + surname_first_parenthetical_hint=normalized_input.surname_first_parenthetical_hint, ) - ): - best_candidate = candidate + used_original = order_tokens == original_tokens + + candidate = { + "surname_tokens": surname_tokens, + "given_tokens": given_tokens, + "score": score, + "order_tokens": order_tokens, + "used_original": used_original, + } + + if ( + best_candidate is None + or candidate["score"] > best_candidate["score"] + or ( + candidate["score"] == best_candidate["score"] + and candidate["used_original"] + and not best_candidate["used_original"] + ) + ): + best_candidate = candidate if best_candidate is not None: surname_tokens = best_candidate["surname_tokens"] @@ -1203,19 +1224,27 @@ def _chinese_classification_input(self, raw_name: str) -> str: @staticmethod def _canonical_components_from_parsed(parsed: ParsedName) -> NameComponents: """Convert legacy parsed components to immutable canonical components.""" - counts = { - "given": len(parsed.given_tokens), - "middle": len(parsed.middle_tokens), - "surname": len(parsed.surname_tokens), - } - occurrences = {role: parsed.order.count(role) for role in counts} - expanded_order: list[str] = [] - for role in parsed.order: - count = counts.get(role, 0) - if occurrences.get(role) == 1: - expanded_order.extend([role] * count) - elif count: - expanded_order.append(role) + if parsed.order == ["given", "middle", "surname"]: + expanded_order = ( + ("given",) * len(parsed.given_tokens) + + ("middle",) * len(parsed.middle_tokens) + + ("surname",) * len(parsed.surname_tokens) + ) + else: + counts = { + "given": len(parsed.given_tokens), + "middle": len(parsed.middle_tokens), + "surname": len(parsed.surname_tokens), + } + occurrences = {role: parsed.order.count(role) for role in counts} + expanded: list[str] = [] + for role in parsed.order: + count = counts.get(role, 0) + if occurrences.get(role) == 1: + expanded.extend([role] * count) + elif count: + expanded.append(role) + expanded_order = tuple(expanded) return NameComponents( given_name=parsed.given_name, middle_name=parsed.middle_name, @@ -1223,7 +1252,7 @@ def _canonical_components_from_parsed(parsed: ParsedName) -> NameComponents: given_tokens=tuple(parsed.given_tokens), middle_tokens=tuple(parsed.middle_tokens), surname_tokens=tuple(parsed.surname_tokens), - order=tuple(expanded_order), + order=expanded_order, ) def _canonical_name_from_chinese_result(self, raw_name: str, result: ParseResult) -> CanonicalName | None: @@ -1243,6 +1272,8 @@ def _canonical_name_from_chinese_result(self, raw_name: str, result: ParseResult @staticmethod def _component_token_key(token: str) -> str: """Return a comparison key for source-to-normalized token lineage.""" + if token.isalnum(): + return token.casefold() return "".join(character.casefold() for character in token if character.isalnum()) @staticmethod @@ -1263,12 +1294,45 @@ def _canonical_source_components( normalized: NameComponents, ) -> NameComponents: """Preserve raw Latin token boundaries while retaining parsed roles.""" + simple_tokens = self._simple_source_tokens(raw_name) + if simple_tokens is not None: + simple_source = self._source_components_from_tokens( + simple_tokens, + parsed_original, + normalized, + require_unique_roles=True, + ) + if simple_source is not None: + return simple_source + source_result = self._person_name_normalizer.normalize_text(raw_name) if source_result.canonical_name is None: return self._canonical_components_from_parsed(parsed_original) generic_source = source_result.canonical_name.source ordered_tokens = self._ordered_component_tokens(generic_source) + source = self._source_components_from_tokens( + ordered_tokens, + parsed_original, + normalized, + require_unique_roles=False, + ) + assert source is not None + return source + + def _simple_source_tokens(self, raw_name: str) -> tuple[str, ...] | None: + """Return already-clean ASCII source tokens, or abstain.""" + return self._normalizer.simple_latin_tokens(raw_name) + + def _source_components_from_tokens( + self, + ordered_tokens: list[str] | tuple[str, ...], + parsed_original: ParsedName, + normalized: NameComponents, + *, + require_unique_roles: bool, + ) -> NameComponents | None: + """Assign source tokens to normalized roles without changing token text.""" normalized_by_role = { "given": normalized.given_tokens, "middle": normalized.middle_tokens, @@ -1288,6 +1352,8 @@ def _canonical_source_components( for index, token in enumerate(ordered_tokens): key = self._component_token_key(token) candidates = [role for role, keys in role_keys.items() if key and key in keys] + if require_unique_roles and len(candidates) != 1: + return None fallback = fallback_roles[index] if index < len(fallback_roles) else "given" role = candidates[0] if len(candidates) == 1 else fallback assigned.append((role, token)) diff --git a/sinonym/services/east_asian_name_order.py b/sinonym/services/east_asian_name_order.py index a11d3be..5a54d27 100644 --- a/sinonym/services/east_asian_name_order.py +++ b/sinonym/services/east_asian_name_order.py @@ -148,12 +148,22 @@ def _contains(values: tuple[str, ...], key: str) -> bool: def _fold(value: str) -> str: + if value.isascii(): + return value.casefold() translated = value.translate(str.maketrans({"\u0110": "D", "\u0111": "d"})) return "".join( character for character in unicodedata.normalize("NFD", translated).casefold() if not unicodedata.combining(character) ) +KOREAN_ROUTING_GIVEN_PARTS = frozenset( + _fold(value) + for value in ( + NAME_ORDER_ROUTING_KOREAN_GIVEN_SYLLABLES | KOREAN_GIVEN_PATTERNS | KOREAN_SPECIFIC_PATTERNS | KOREAN_AMBIGUOUS_PATTERNS + ) +) + + def _japanese_roman_keys(value: str) -> tuple[str, ...]: exact = _fold(value) collapsed = exact.replace("ou", "o").replace("oo", "o").replace("uu", "u") @@ -298,18 +308,9 @@ def _infer_korean( return None if _contains(lexicons.korean_surnames, _fold(tokens[-1])): return None - known_given = { - _fold(value) - for value in ( - NAME_ORDER_ROUTING_KOREAN_GIVEN_SYLLABLES - | KOREAN_GIVEN_PATTERNS - | KOREAN_SPECIFIC_PATTERNS - | KOREAN_AMBIGUOUS_PATTERNS - ) - } given_parts = [_fold(part) for token in tokens[1:] for part in token.split("-") if part] has_hyphen = any("-" in token for token in tokens[1:]) - all_known = bool(given_parts) and all(part in known_given for part in given_parts) + all_known = bool(given_parts) and all(part in KOREAN_ROUTING_GIVEN_PARTS for part in given_parts) if not (has_hyphen or (len(tokens) == MAX_KOREAN_ROMANIZED_TOKENS and all_known)): return None given_tokens = tuple(tokens[1:]) diff --git a/sinonym/services/ethnicity.py b/sinonym/services/ethnicity.py index 1c6aacd..21ebb02 100644 --- a/sinonym/services/ethnicity.py +++ b/sinonym/services/ethnicity.py @@ -9,6 +9,7 @@ from __future__ import annotations import logging +from functools import lru_cache from sinonym.chinese_names_data import ( COMPOUND_VARIANTS, @@ -39,6 +40,7 @@ MIN_DIRECTIONAL_KOREAN_TOKENS = 2 MAX_DIRECTIONAL_KOREAN_TOKENS = 3 MIN_CONTEXTUAL_TAIWAN_SURNAME_FREQUENCY = 100.0 +MIN_CHINESE_SURNAME_STRENGTH = 0.5 CONTEXTUAL_TAIWAN_GIVEN_PARTS = { "jungting": ("jung", "ting"), "tsung-jr": ("tsung", "jr"), @@ -305,10 +307,8 @@ def check_overlapping_surname(token): # TIER 3: CHINESE DEFAULT (Low Confidence) # ================================================================= - chinese_surname_strength = self._calculate_chinese_surname_strength(expanded_keys, normalized_cache) - # Chinese default: Accept if we have any reasonable Chinese surname evidence - if chinese_surname_strength >= 0.5: + if self._has_sufficient_chinese_surname_strength(expanded_keys, normalized_cache): return ParseResult.success_with_name("") # No Chinese evidence found @@ -347,6 +347,7 @@ def _split_roman_components(tokens: tuple[str, ...]) -> list[str]: return [part.lower() for token in tokens for part in token.split("-") if part and part.isalpha()] @classmethod + @lru_cache(maxsize=4096) def _has_directional_korean_structure(cls, tokens: tuple[str, ...]) -> bool: """Return whether surname and given evidence align as a Korean name.""" if not MIN_DIRECTIONAL_KOREAN_TOKENS <= len(tokens) <= MAX_DIRECTIONAL_KOREAN_TOKENS: @@ -616,8 +617,12 @@ def _calculate_vietnamese_score_from_analysis( return score - def _calculate_chinese_surname_strength(self, expanded_keys: list[str], normalized_cache: dict[str, str]) -> float: - """Calculate Chinese surname strength (simplified from original).""" + def _has_sufficient_chinese_surname_strength( + self, + expanded_keys: list[str], + normalized_cache: dict[str, str], + ) -> bool: + """Return whether nonnegative surname evidence reaches the Chinese threshold.""" chinese_surname_strength = 0.0 # Local memoization for repeated split/component checks @@ -649,6 +654,8 @@ def _calculate_chinese_surname_strength(self, expanded_keys: list[str], normaliz base_strength = 0.2 chinese_surname_strength += base_strength + if chinese_surname_strength >= MIN_CHINESE_SURNAME_STRENGTH: + return True # Check for compact compound surnames in COMPOUND_VARIANTS elif clean_key_lower in COMPOUND_VARIANTS: # This is a compact compound surname - give it good strength @@ -660,7 +667,7 @@ def _calculate_chinese_surname_strength(self, expanded_keys: list[str], normaliz part1, part2 = compound_parts if part1 in self._data.surnames_normalized and part2 in self._data.surnames_normalized: # Both parts are valid Chinese surnames, give high confidence - chinese_surname_strength += 1.0 + return True else: # NEW: Check if this could be a compound Chinese given name if clean_key_lower in split_result_cache: @@ -698,5 +705,7 @@ def _calculate_chinese_surname_strength(self, expanded_keys: list[str], normaliz if all_chinese_components: # Add modest boost for compound given names (helps cases like "Beining") chinese_surname_strength += 0.3 + if chinese_surname_strength >= MIN_CHINESE_SURNAME_STRENGTH: + return True - return chinese_surname_strength + return False diff --git a/sinonym/services/name_lookup.py b/sinonym/services/name_lookup.py index 1df391b..7bff809 100644 --- a/sinonym/services/name_lookup.py +++ b/sinonym/services/name_lookup.py @@ -112,8 +112,10 @@ def _parser_key(self, surname_tokens: Sequence[str]) -> str: """Derive the parser-strict surname key used by parse scoring.""" self._require_tokens(surname_tokens) cache_key = tuple(surname_tokens) - if cache_key in self._parser_key_cache: + try: return self._parser_key_cache[cache_key] + except KeyError: + pass if len(cache_key) == 1: parser_key = self._single_parser_key(cache_key[0]) @@ -146,12 +148,15 @@ def _contains_cjk(self, token: str) -> bool: def _evidence_key(self, token: str) -> str: """Derive the as-written surname evidence key.""" - if token not in self._evidence_key_cache: - self._evidence_key_cache[token] = self._data.surname_lookup_key( + try: + return self._evidence_key_cache[token] + except KeyError: + evidence_key = self._data.surname_lookup_key( self._normalizer.norm_light(token), self._normalizer.norm(token), ) - return self._evidence_key_cache[token] + self._evidence_key_cache[token] = evidence_key + return evidence_key def _is_wade_giles_initial_remapped_surname_token(self, token: str) -> bool: """Return whether a direct surname was remapped by a Wade-Giles initial rule.""" diff --git a/sinonym/services/non_person.py b/sinonym/services/non_person.py index 86aab9f..2ff9996 100644 --- a/sinonym/services/non_person.py +++ b/sinonym/services/non_person.py @@ -59,6 +59,8 @@ def __init__( def failure_reason(self, raw_name: str) -> str | None: """Return a failure reason when the input is clearly not one personal name.""" + if raw_name.isascii(): + return NON_PERSON_FAILURE_REASON if self._has_latin_author_list_shape(raw_name) else None if ( self._has_cjk_non_person_marker(raw_name) or self._has_latin_author_list_shape(raw_name) @@ -177,6 +179,8 @@ def _is_trailing_latin_initial_suffix(raw_name: str, letter_index: int) -> bool: def _cjk_chunks(self, raw_name: str) -> list[str]: """Return contiguous CJK runs split by non-CJK separators.""" + if raw_name.isascii(): + return [] chunks: list[str] = [] current: list[str] = [] @@ -196,7 +200,7 @@ def _cjk_chunks(self, raw_name: str) -> list[str]: def _has_latin_author_list_shape(self, raw_name: str) -> bool: """Return whether a Latin string looks like several Chinese author names collapsed together.""" - if self._config.cjk_pattern.search(raw_name): + if not raw_name.isascii() and self._config.cjk_pattern.search(raw_name): return False tokens = LATIN_WORD_RE.findall(raw_name) diff --git a/sinonym/services/normalization.py b/sinonym/services/normalization.py index 8ebf2ad..45a089c 100644 --- a/sinonym/services/normalization.py +++ b/sinonym/services/normalization.py @@ -9,6 +9,7 @@ import string from dataclasses import dataclass +from functools import lru_cache from typing import TYPE_CHECKING, Literal from sinonym.chinese_names_data import VALID_CHINESE_RIMES @@ -142,7 +143,9 @@ def get_normalized(self, token: str, norm_cache: dict[str, str]) -> str: Consolidates the common pattern: normalized_cache.get(token, self.norm(token)) """ - return norm_cache.get(token, self.norm(token)) + if token in norm_cache: + return norm_cache[token] + return self.norm(token) def apply(self, raw_name: str) -> NormalizedInput: """ @@ -152,6 +155,19 @@ def apply(self, raw_name: str) -> NormalizedInput: if not raw_name or not raw_name.strip(): return NormalizedInput.empty(raw_name) + simple_tokens = self.simple_latin_tokens(raw_name) + if simple_tokens is not None: + norm_map = {token: self._text_normalizer.normalize_token(token) for token in simple_tokens} + compound_metadata = self._compound_detector.generate_compound_metadata(simple_tokens, self._data) + return NormalizedInput( + raw=raw_name, + cleaned=raw_name, + tokens=simple_tokens, + roman_tokens=simple_tokens, + norm_map=norm_map, + compound_metadata=compound_metadata, + ) + # Phase 1: Clean input (single regex pass) cleaned, from_camel_case_pair, surname_first_parenthetical_hint = self._text_preprocessor.preprocess_input( raw_name, @@ -197,6 +213,21 @@ def apply(self, raw_name: str) -> NormalizedInput: surname_first_parenthetical_hint=surname_first_parenthetical_hint, ) + @staticmethod + @lru_cache(maxsize=4096) + def simple_latin_tokens(raw_name: str) -> tuple[str, ...] | None: + """Return clean space-delimited ASCII name tokens, or abstain.""" + if not raw_name.isascii() or raw_name != raw_name.strip(): + return None + tokens = tuple(raw_name.split(" ")) + if len(tokens) < 2 or any(not token for token in tokens): + return None + for token in tokens: + parts = token.replace("'", "-").split("-") + if any(not part.isalpha() for part in parts): + return None + return tokens + def _process_mixed_tokens(self, tokens: list[str], is_all_chinese: bool = False) -> list[str]: """Extract existing mixed token processing logic with enhanced all-Chinese support.""" mix = [] diff --git a/sinonym/services/parsing.py b/sinonym/services/parsing.py index 0ecf94d..e65a262 100644 --- a/sinonym/services/parsing.py +++ b/sinonym/services/parsing.py @@ -345,9 +345,13 @@ def _best_parse_tokens( normalized_cache, compound_metadata, ) - parses = [(surname, given) for surname, given, _ in parses_with_format] - if not parses: + if not parses_with_format: return None + if len(parses_with_format) == 1: + surname_tokens, given_tokens, original_compound_format = parses_with_format[0] + needs_initial_guard = any(len(token) == 1 for token in given_tokens) and any(len(token) > 3 for token in tokens) + if not needs_initial_guard: + return surname_tokens, given_tokens, original_compound_format scored_parses = [] has_multi_syllable_tokens = any(len(token) > 3 for token in tokens) diff --git a/sinonym/services/person_name_normalization.py b/sinonym/services/person_name_normalization.py index ee7be73..95d5cb7 100644 --- a/sinonym/services/person_name_normalization.py +++ b/sinonym/services/person_name_normalization.py @@ -248,6 +248,15 @@ class _DroppedToken: _RAW_ROMAN_SUFFIXES = frozenset({"II", "III", "IV", "VI", "VII", "VIII", "IX", "X"}) _EXPLICIT_ROMAN_SUFFIXES = _RAW_ROMAN_SUFFIXES | {"I", "V", "X"} _CASE_INSENSITIVE_ROMAN_SUFFIXES = _RAW_ROMAN_SUFFIXES - {"II"} +_SIMPLE_TWO_TOKEN_POLICY_KEYS = frozenset( + _TITLE_KEYS + | _TITLE_QUALIFIER_KEYS + | _CREDENTIAL_KEYS + | _ORGANIZATION_WORDS + | _STANDARD_SUFFIXES.keys() + | {suffix.casefold() for suffix in _RAW_ROMAN_SUFFIXES} + | {"and"}, +) _TWO_COMPONENTS = 2 _THREE_COMPONENTS = 3 _FOUR_COMPONENTS = 4 @@ -265,6 +274,10 @@ def normalize_text(self, raw_name: str | None) -> PersonNameNormalizationResult: if not isinstance(raw_name, str): return self._invalid("name must be a string") + simple_result = self._normalize_simple_two_token_text(raw_name) + if simple_result is not None: + return simple_result + source_text = raw_name normalized_input, leading_markers = self._strip_leading_superscript_affiliation(raw_name) surface = self._normalize_surface(normalized_input) @@ -331,6 +344,25 @@ def normalize_text(self, raw_name: str | None) -> PersonNameNormalizationResult: ) return self._normalize_regular_name(source_text, segments[0], suffix, suffix_token, dropped) + def _normalize_simple_two_token_text(self, raw_name: str) -> PersonNameNormalizationResult | None: + """Normalize policy-neutral two-token ASCII names without preprocessing.""" + if not raw_name.isascii() or raw_name != raw_name.strip(): + return None + raw_tokens = raw_name.split(" ") + if len(raw_tokens) != _TWO_COMPONENTS or any(not token.isalpha() for token in raw_tokens): + return None + if any(token.casefold() in _SIMPLE_TWO_TOKEN_POLICY_KEYS for token in raw_tokens): + return None + + source_tokens = [ + _Token(raw_tokens[0], "", 0), + _Token(raw_tokens[1], "", len(raw_tokens[0]) + 1), + ] + given = [replace(source_tokens[0], source_role="given")] + surname = [replace(source_tokens[1], source_role="surname")] + source = self._components(given, [], surname, []) + return self._person(raw_name, source, given, [], surname, "", []) + def normalize_components( # noqa: C901, PLR0911 self, *, @@ -682,6 +714,8 @@ def _tokens(value: str, source_role: str, offset: int) -> list[_Token]: @staticmethod def _compact_key(token: str) -> str: + if token.isalnum(): + return token.casefold() return "".join(character.casefold() for character in token if character.isalnum()) def _strip_leading_titles( diff --git a/sinonym/text_processing/text_preprocessor.py b/sinonym/text_processing/text_preprocessor.py index 3a5b431..842688a 100644 --- a/sinonym/text_processing/text_preprocessor.py +++ b/sinonym/text_processing/text_preprocessor.py @@ -140,6 +140,8 @@ def compact_all_chinese_input(self, text: str) -> str: """Return compact CJK text when the input contains only CJK plus separators.""" if not text or not text.strip(): return "" + if text.isascii(): + return "" cleaned = self._config.sep_pattern.sub("", text.strip()) cleaned = self._config.whitespace_pattern.sub("", cleaned) @@ -202,6 +204,8 @@ def contains_non_chinese_scripts(self, text: str) -> bool: """ if not text: return False + if text.isascii(): + return False # Vietnamese-specific characters (conservative list to avoid Pinyin overlap) vietnamese_specific = "ĐđăâêôơưĂÂÊÔƠƯ" diff --git a/tests/test_canonical_name_integration.py b/tests/test_canonical_name_integration.py index 13d7dfd..28120f6 100644 --- a/tests/test_canonical_name_integration.py +++ b/tests/test_canonical_name_integration.py @@ -39,6 +39,28 @@ def test_canonical_name_moves_true_suffix_to_suffix_component(detector): assert result.canonical_name.normalized.order == ("given", "surname", "suffix") +def test_two_token_suffixes_and_credentials_use_full_canonical_pipeline(detector): + cases = ( + ("John Jr", "John Jr.", "Jr."), + ("John Senior", "John Sr.", "Sr."), + ("John Phd", "John", ""), + ("Phd Smith", "Smith", ""), + ("PD Dr", None, None), + ) + + for raw_name, expected_text, expected_suffix in cases: + public = detector.normalize_name(raw_name).canonical_name + generic = detector.normalize_person_name(raw_name) + + assert public == generic + if expected_text is None: + assert public is None + continue + assert public is not None + assert public.text == expected_text + assert public.normalized.suffix == expected_suffix + + def test_structured_component_normalization_repairs_roles_after_drops(detector): canonical = detector.normalize_person_name_components( first_name="dr steve", diff --git a/tests/test_chinese_classification_evidence_gates.py b/tests/test_chinese_classification_evidence_gates.py index 45b630e..397e89c 100644 --- a/tests/test_chinese_classification_evidence_gates.py +++ b/tests/test_chinese_classification_evidence_gates.py @@ -86,6 +86,17 @@ def test_directional_korean_gate_preserves_reviewed_chinese_controls( assert detector.normalize_name(raw_name).success +@pytest.mark.parametrize("raw_name", ["Zhang Thi", "Wang Thi", "Thi Zhang", "Thi Wang"]) +def test_vietnamese_thi_veto_precedes_chinese_surname_evidence( + detector: ChineseNameDetector, + raw_name: str, +) -> None: + result = detector.normalize_name(raw_name) + + assert not result.success + assert result.error_message == "appears to be Vietnamese name" + + @pytest.mark.parametrize( ("raw_name", "expected"), [ diff --git a/tests/test_person_name_normalization.py b/tests/test_person_name_normalization.py index b732625..df3f2db 100644 --- a/tests/test_person_name_normalization.py +++ b/tests/test_person_name_normalization.py @@ -95,6 +95,35 @@ class UnexpectedUnicodeData: ) +@pytest.mark.parametrize("raw_name", ["John Smith", "JOHN SMITH", "jOhN mCdonald", "A Li", "CS Smith", "Ben Sherwood"]) +def test_simple_two_token_fast_path_matches_full_pipeline( + normalizer: PersonNameNormalizationService, + monkeypatch: pytest.MonkeyPatch, + raw_name: str, +) -> None: + fast_result = normalizer._normalize_simple_two_token_text(raw_name) # noqa: SLF001 + assert fast_result is not None + + monkeypatch.setattr( + PersonNameNormalizationService, + "_normalize_simple_two_token_text", + lambda _self, _raw_name: None, + ) + + assert normalizer.normalize_text(raw_name) == fast_result + + +@pytest.mark.parametrize( + "raw_name", + ["John Jr", "John Phd", "Phd Smith", "PD Dr", "John University", "John AND", "John II"], +) +def test_simple_two_token_fast_path_abstains_on_policy_tokens( + normalizer: PersonNameNormalizationService, + raw_name: str, +) -> None: + assert normalizer._normalize_simple_two_token_text(raw_name) is None # noqa: SLF001 + + def test_normalize_text_strips_stacked_title_and_credentials_at_boundaries( normalizer: PersonNameNormalizationService, ) -> None: