diff --git a/AGENTS.md b/AGENTS.md index 9ede3b1..0124076 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -176,7 +176,7 @@ Add a dedicated `copy.deepcopy()` round-trip test for it too (see `test_regexes_ **Cyrillic suffix regexes need `re.I` even when the pattern is suffix-only** — a Latin title-cased word (`Ivanovich`) keeps its suffix lowercase, so `re.I` seemed skippable; but an irregular Cyrillic suffix can be nearly the whole word (`ильич`), so title-casing capitalizes into the suffix itself (`Ильич`). `east_slavic_patronymic_cyrillic` shipped without `re.I` on the Latin reasoning and silently failed on capitalized irregular forms — don't assume Latin's title-case safety transfers to Cyrillic. (#185) -**`suffix_not_acronyms` vs `is_an_initial` tension** — single-letter roman numeral suffixes (`i`, `v`) are in `suffix_not_acronyms` but also match the `is_an_initial` regex (single uppercase letter), so `is_suffix()` rejects them. The lenient test lives in `is_suffix_lenient()`, which accepts `suffix_not_acronyms` members unconditionally and is only safe in unambiguous positions: (1) suffix-comma detection uses it via `are_suffixes_after_comma()`; (2) lastname-comma post-comma parsing uses it inline, only when `nxt is None` and `len(parts)==2` (no `parts[2]` suffix segment). See issues #136, #144. +**`suffix_not_acronyms` vs `is_an_initial` tension** — single-letter roman numeral suffixes (`i`, `v`) are in `suffix_not_acronyms` but also match the `is_an_initial` regex (single uppercase letter), so `is_suffix()` rejects them. The lenient test lives in `is_suffix_lenient()`, which accepts `suffix_not_acronyms` members unconditionally and is only safe in unambiguous positions: (1) suffix-comma detection uses it via `are_suffixes_after_comma()`; (2) lastname-comma post-comma parsing uses it inline, only when `nxt is None` and `len(parts)==2` (no `parts[2]` suffix segment). See issues #136, #144. **The tension is Latin-only since #320** — the veto is now scoped to scripts that HAVE initials, and v1's single `is_an_initial` is now two predicates, of which the tension is about `_vocab.is_initial` (the one `is_suffix()`/`is_suffix_strict` vetoes with; `is_initial_shaped` is the bare v1 shape test and vetoes nothing) — `_vocab.is_initial` ANDs that shape test with "not in `_policy._NO_INITIALS`", that constant listing the four scripts which do NOT (Han, Hangul, Hiragana, Katakana) — so the seven single-character CJK members of `suffix_not_acronyms` (씨, 様, 氏, 군, 양, 님, 殿) no longer collide with it when written with a period: `"김민준 씨."` gives suffix `씨.` where the veto had made it the family name, and `"김민준, 씨."` where it had made it the given name. Latin is untouched — `V.`/`I.` still lose to the veto and still need `is_suffix_lenient()`. **Comparing against v1 needs `PYTHONSAFEPATH=1` AND a directory outside the worktree** — `uv run --isolated --no-project --with 'nameparser==1.4.0'` still puts the checkout's `nameparser/` ahead of the pinned wheel on `sys.path`, so the "v1" side silently imports the branch and every comparison reports parity. Run it as `cd && PYTHONSAFEPATH=1 uv run --isolated --no-project --with 'nameparser==1.4.0' python -c "..."`. This produces false confidence rather than an error, so it invalidates results without ever looking wrong. diff --git a/docs/modules.rst b/docs/modules.rst index 7f50dee..41755f3 100644 --- a/docs/modules.rst +++ b/docs/modules.rst @@ -50,7 +50,8 @@ Results "de"/"van", wherever it lands — combine with ``Role.FAMILY`` for actual family particles), ``conjunction`` (a joining word, "and"/"y"), ``initial`` (an - initial-shaped word, "J."), and ``joined`` (a continuation of the + initial-shaped word in a script that HAS initials — "J." or "А.", + never "씨."), and ``joined`` (a continuation of the previous token within one merged piece, so the suffix view renders "Ph. D." as one credential). Every other tag is namespaced (``vocab:...``) and unstable — never match against those. diff --git a/docs/release_log.rst b/docs/release_log.rst index 6eb8994..848b599 100644 --- a/docs/release_log.rst +++ b/docs/release_log.rst @@ -29,6 +29,7 @@ Release Log - Fix spaced CJK postnominal honorifics parsing as name parts: 씨, 박사, 선생님, 교수님, 군, 양 (Korean — standardly written as their own token), 先生, 女士, 小姐, 博士, 教授 (Chinese, with 先生/博士/教授 shared with Japanese), and 様, 氏 (Japanese) now route to ``suffix``, so ``王小明 先生`` reads family ``王小明`` where the family-first default had confidently made 先生 the given name. Whole-token matching, which also reaches a glued surname+honorific token, since segmentation splits off the surname first (``김씨`` reads family 김, suffix 씨). The glued forms whole-token matching cannot reach are handled by the peel described below (closes #307) - Fix glued CJK honorifics parsing as part of the name: an honorific written against the name — ``田中さん``, ``山田太郎様``, ``김민준씨``, ``김민준님``, ``王小明先生`` — is now split off the end of the last name token and routed to ``suffix``, where it had been swallowed by the name (the whole of ``田中さん`` was the family name; ``김민준씨`` gave given 민준씨). The peeled name then goes through the ordinary machinery, so the Korean split still happens (``김민준씨`` → family 김, given 민준, suffix 씨) and the ``田中さん`` case stops misreading as a kana-licensed composite. Only entries that can never END a name peel — 씨, 님, 박사, 박사님, 선생님, 교수님, さん, さま, くん, ちゃん, 様, 先生, 教授, 女士, 小姐 — while 양, 군, 氏, 博士 and 殿 are recognized in their spaced form only, because 김지양 is a given name, 田中博士 is Tanaka Hiroshi as readily as Doctor Tanaka, and some ninety Japanese surnames end in 殿 (鵜殿, 真殿); 君 is recognized in neither form, since 王君 is a complete Chinese name, though its kana spelling くん peels. Seven entries are new vocabulary in this change (さん, さま, くん, ちゃん, 殿, 님, 박사님), so their spaced forms route to ``suffix`` too (``田中 さん``, ``田中 殿``, ``김민준 님``, ``김민준 박사님``). 박사님 closes a gap in the shipped set rather than opening new ground: 선생님 and 교수님 shipped in 2.1 without it, so ``김민준박사님`` stranded 박사 in the given name and the spaced ``김민준 박사님`` came back as two suffixes for one honorific. Exactly one honorific peels off a token, and every entry is a whole honorific rather than a part of one. A token that is nothing but an honorific is no longer taken apart either — ``선생님`` and ``박사`` now stay whole where hangul segmentation had split them 선 + 생님 and 박 + 사, since 선 and 박 are listed surnames — which is **default-on** in its own right, hangul segmentation being a default. A configured segmenter benefits twice over: it is handed the name without the honorific, and the honorific the peel just cut off does not then look to it like a boundary the writer drew — so ``parser_for(locales.JA, segmenter=ja_segmenter())`` reads ``山田太郎様`` as family 山田, given 太郎, suffix 様. Worth knowing before you upgrade: that exemption is what makes a GLUED honorific stop protecting a name from division, so a family name written alone with one — ``田中さん`` — now divides the way bare ``田中`` already did (family 田, given 中, suffix さん). It is exactly and only the peeled tail that is exempt. A SPACED honorific is a token boundary its writer typed, and anything standing beside a name calls the segmenter off, so the name is left as written: ``田中 さん`` and ``佐藤 氏`` keep family 田中 and 佐藤 under the pack — the division the pack gives them without this change, whichever field the honorific itself lands in. That is the conservative reading rather than a claim about intent: a spaced honorific cannot be told apart from a spaced given name by position, and counting it as one keeps four real surnames whole (``佐藤 氏``, ``田中 様``, ``鈴木 先生``, ``中村 教授``) at the price of the one division it then declines to make (``山田太郎 様``). Writing the honorific spaced is therefore an opt-out in its own right on the SEGMENTER path, alongside declining the pack or the segmenter. It is no lever where the VOCABULARY divides the name, the two spellings agreeing exactly there — ``김민준 씨`` and ``김민준씨`` both give family 김, given 민준, suffix 씨, as do ``王小明 先生`` and ``王小明先生`` under the Chinese pack — and Korean data has no pack to decline either, hangul segmentation being on by default. **Default-on: changes parse output for glued CJK honorific forms**, through ``HumanName`` as well as the 2.0 API (closes #308) - Fix a comma or a 间隔号 stopping the glued-honorific peel: an honorific written against the name is now split off and routed to ``suffix``, so ``김, 민준씨`` gives family 김, given 민준, suffix 씨 — the same as the spaced ``김 민준씨`` — and likewise ``田中, 太郎さん``, which also matches its spaced form. ``田中さん, PhD`` and ``威廉·莎士比亚さん`` peel too; the first of those does *not* otherwise match its spaced form, since ``田中さん PhD`` leaves PhD in ``suffix`` beside さん while after a comma it reads as a ``title`` — where the credential lands is the comma's business, not the peel's. Previously each of these left the honorific inside the name. A comma no longer switches the peel off; what it does now is say which runs of the name to look in, and those are the two around a family comma, an honorific being as often glued to the given name as to the family. Anything past those two runs is out of reach, which is the one limit worth knowing: ``김, 민준 지훈씨`` peels, while ``김, 민준, 지훈씨`` (a third run) and ``김,, 민준씨`` (a doubled comma, which puts the name in a later run) do not. The reach also rests on the second run being name text, and a one-word part before the comma reads as a family comma even when the part after it is entirely suffix-shaped — so ``田中さん, V.`` keeps さん in the family name where ``田中さん, PhD`` gives it up. Same credential, opposite answer, and unchanged from 1.4.0 in that spelling. The 间隔号 does not switch the peel off either. Both marks say where a name divides into surname and given, and an honorific is not part of the name in either reading. The surname split still stands down for both, unchanged — a comma still means the writer said where the family name ends, and the 间隔号 still marks a transcription. ``田中さん, 太郎`` is unaffected, because the honorific there is not at the end of the name: 太郎 is. **Default-on**, and it reaches ``HumanName`` too (closes #312) + - Fix an ASCII period after a CJK honorific stopping it being recognized: ``씨.``, ``様.``, ``氏.``, ``님.``, ``군.``, ``양.`` and ``殿.`` now route to ``suffix`` like their periodless spellings, where the trailing period had left them inside the name — the family name in ``"김민준 씨."``, the given name in ``"김민준, 씨."``. The cause was v1's initial regex, ``^(\w\.|[A-Z])?$`` (``REGEXES["initial"]``, still public v1 API), whose ``\w`` is Unicode-aware and so matched a hangul syllable or a Han ideograph as readily as a letter; the strict suffix test applies that as a veto (``V.`` in ``"John V. Smith"`` is a middle initial, not roman five), and a veto written for Latin was being asked of scripts it was never about. The cost ran past the honorific itself: because the vetoed token read as name text, the glued-honorific peel's scan back for its site stopped at it instead of stepping over it, took it as the site, found no honorific at the end of it and gave up — so ``"田中さん, 様."`` kept ``さん`` inside the family name while ``"田中さん, 様"`` peeled it. Measured against 1.4.0, ``"김민준, 씨."`` and ``"田中さん, 様."`` were returning exactly what 1.x returns, so the honorific work earlier in this release had a hole in it wherever the honorific was written with a period. An initial is a single LETTER standing in for a name, and Han ideographs, hangul syllables and kana are morphemes and syllables rather than letters, so the veto now asks its question only of the scripts where it means something. Alphabets keep their initials untouched — ``"А. С. Пушкин"``, ``"م. الفارسي"`` and ``"Ա. Խաչատրյան"`` are unaffected, and so is the Ukrainian conjunction entry below, where a punctuated ``Й.`` still outranks the conjunction ``й``. The public ``initial`` tag follows the same line: ``씨.`` no longer carries it. Read ``period`` strictly here: the fix is scoped to the ASCII full stop U+002E, because that is the only period ``_normalize`` strips. The fullwidth U+FF0E and the ideographic U+3002 (with its halfwidth twin U+FF61) — the stops a CJK writer is likelier to type — leave the honorific unmatchable by the vocabulary lookup, which runs before the veto is ever consulted, so ``"김민준 씨."`` still reads the honorific as the family name. That is a separate, still-open gap in ``_normalize`` rather than in the veto: those spellings parse identically before and after this change, and widening the strip is follow-up work. **Default-on**, and it reaches ``HumanName`` too (#320) - Fix NFD-decomposed input missing the East Asian defaults entirely: script classification now normalizes to NFC before deciding, so a Korean or Japanese name typed on macOS — where decomposed text is routine — gets the same order rule as its composed twin, which it silently did not before. Segmentation MATCHING deliberately stays raw, so an unspaced NFD hangul name is ordered correctly but not split, rather than being split in the wrong place. One gotcha worth stating plainly: parse output preserves the encoding it was given, so for NFD input ``name.family == "김"`` is ``False`` even though it is the same name — compare NFC-normalized text when comparing across encodings (#272) - Fix the Ukrainian conjunction ``й`` not joining the pieces around it: it is the euphonic alternate of ``і``, the two chosen by the surrounding vowel and consonant rather than by meaning (``"Олесь і Олена"`` but ``"Марія й Петро"``), so real Ukrainian data carries both spellings and shipping only ``і`` recognized just one of them. ``"Олесь й Олена Коваленки"`` now gives given ``"Олесь й Олена"`` where the ``й`` previously landed in ``middle``. Same treatment as the ``и``/``і`` entries added in 2.0.0, single-letter carve-out included: the conjunction joins only once the name has enough pieces, and a punctuated initial still wins, so ``"Й. Сліпий"`` is unaffected. Raised in a comment on #267 diff --git a/nameparser/_pipeline/_assign.py b/nameparser/_pipeline/_assign.py index 0407736..9e63cf9 100644 --- a/nameparser/_pipeline/_assign.py +++ b/nameparser/_pipeline/_assign.py @@ -31,7 +31,8 @@ import re from nameparser._pipeline._vocab import ( - effective_script, is_suffix_lenient, resolve_script_set, + effective_script, is_initial_shaped, is_suffix_lenient, + resolve_script_set, ) from nameparser._pipeline._group import ( _is_suffix_piece, _is_title_piece, @@ -200,9 +201,16 @@ def _assign_main(seg_idx: int, state: ParseState, if _is_suffix_piece(piece, tags, tokens): k -= 1 continue + # is_initial_shaped, not the "initial" tag: this asks whether + # the preceding piece looks like part of an initial run, which + # is a question about layout, and #320 narrowed the tag to + # initials that can really stand in for a name. Reading the tag + # here made '씨.' stop suppressing the fork and cost 'John 씨. V' + # its family name. if (k == len(rest) and k >= 2 and len(piece) == 1 and _ROMAN.match(tokens[piece[0]].text) - and "initial" not in tokens[pieces[rest[k - 2]][0]].tags): + and not is_initial_shaped( + tokens[pieces[rest[k - 2]][0]].text)): # a trailing single letter is a name part unless it happens # to be a roman numeral -- and V/X/I are ordinary middle # initials, so taking it as a suffix is a call, not a fact diff --git a/nameparser/_pipeline/_classify.py b/nameparser/_pipeline/_classify.py index 78bd988..a52132c 100644 --- a/nameparser/_pipeline/_classify.py +++ b/nameparser/_pipeline/_classify.py @@ -2,7 +2,9 @@ Consumes: tokens. Produces: tokens with vocabulary tags added (text/span/role unchanged). -Reads: every Lexicon vocabulary field; Policy is not consulted. +Reads: every Lexicon vocabulary field; no Policy FIELD is consulted +(is_initial does consult the _policy module's _NO_INITIALS constant, +which is not configuration -- nothing here varies by Policy value). Tags emitted -- stable (API): "particle", "conjunction", "initial"; namespaced (unstable): "vocab:title", "vocab:given-title", diff --git a/nameparser/_pipeline/_vocab.py b/nameparser/_pipeline/_vocab.py index d0e9b31..86afd03 100644 --- a/nameparser/_pipeline/_vocab.py +++ b/nameparser/_pipeline/_vocab.py @@ -13,12 +13,22 @@ from collections.abc import Callable, Iterable from nameparser._lexicon import Lexicon, _normalize -from nameparser._policy import (Script, _JA_SCRIPTS, _SCRIPT_RANGES, - _script_matcher) +from nameparser._policy import (Script, _JA_SCRIPTS, _NO_INITIALS, + _SCRIPT_RANGES, _script_matcher) # Ported verbatim from v1 (nameparser/config/regexes.py "initial") minus # its empty-string alternative -- WorkToken text is never empty. Kept in # sync by hand; layering forbids importing the config package here. +# "Verbatim" is a promise about the PATTERN, not about the predicate: +# since #320 is_initial is this SHAPE test ANDed with a repertoire test +# (_in_initialless_script, below), so _INITIAL.fullmatch(text) and +# is_initial(text) are no longer the same question -- '씨.' answers yes +# to the first and no to the second. Call is_initial; the bare pattern +# is not the thing to ask. The narrowing lives in the predicate +# precisely so this copy can stay exactly as verbatim as it ever was +# -- REGEXES["initial"] is public v1 API and cannot narrow, and the +# only difference between the two remains the empty alternative noted +# above (config's `?`), which test_regex_sync splices back in. _INITIAL = re.compile(r"^(\w\.|[A-Z])$") # Ported verbatim from v1 (nameparser/config/regexes.py @@ -51,10 +61,38 @@ # effective_script's kana license. _wholly_ja = _script_matcher(*_JA_SCRIPTS, whole=True) +# The repertoire half of is_initial (_policy._NO_INITIALS), kept apart +# from _INITIAL's SHAPE half so the pattern itself stays v1-verbatim +# and its three copies stay pinned by tests/v2/test_regex_sync.py. +# contains-any, not whole=True: the shape half has already admitted the +# trailing period, so the text reaching here is '씨.' rather than '씨' +# and a wholly-of match would be False for every case this exists for. +_in_initialless_script = _script_matcher(*_NO_INITIALS, whole=False) + + +def is_initial_shaped(text: str) -> bool: + """v1's is_an_initial verbatim: the SHAPE half alone -- one word + character plus a period, or a bare ASCII capital. + + Callers asking whether a token is STRUCTURALLY part of an initial + run want this; callers asking whether it can really stand in for a + name want is_initial (#320). The two answers differ only inside + _NO_INITIALS scripts, where '씨.' is initial-SHAPED but is not an + initial -- see assign's roman-numeral fork, the shape caller, for + what picking the wrong one costs.""" + return bool(_INITIAL.fullmatch(text)) + def is_initial(text: str) -> bool: - """'A.' / 'j.' / bare capital -- v1's is_an_initial.""" - return bool(_INITIAL.fullmatch(text)) + """'A.' / 'j.' / bare capital -- v1's is_an_initial, narrowed to + scripts that HAVE initials (#320). v1's \\w is Unicode-aware and + matched CJK too, which made period-written CJK honorifics ('씨.') + fail is_suffix_strict -- the veto in _is_suffix_strict_n, NOT the + vocabulary: suffix_as_written has no veto, so classify tagged '씨.' + 'vocab:suffix' either way, and is_suffix_lenient took it either way + too. Downstream of that one strict-test No, the glued honorific in + a name carrying such a token went unpeeled ('田中さん 様.').""" + return is_initial_shaped(text) and not _in_initialless_script(text) def suffix_as_written(n: str, text: str, lexicon: Lexicon) -> bool: diff --git a/nameparser/_policy.py b/nameparser/_policy.py index e8b92bd..dbe8b76 100644 --- a/nameparser/_policy.py +++ b/nameparser/_policy.py @@ -142,6 +142,30 @@ class Script(StrEnum): #: quantify over this one union (HANGUL simply omitted). _JA_SCRIPTS = (Script.HAN, Script.HIRAGANA, Script.KATAKANA) +#: Scripts whose characters cannot BE an initial. The criterion is +#: orthographic CONVENTION, not what a character is: does the writing +#: tradition abbreviate a given name to ONE character plus a period, +#: the way "J." stands in for "John"? Han, hangul and kana have no +#: such convention, so a lone punctuated 씨/様/김 is not a shortened +#: name and the veto has nothing to veto there. Do not restate that +#: phonologically ("letters, not syllables") -- Devanagari is an +#: abugida and Arabic an abjad, neither has letters in that sense, and +#: both abbreviate, so should Script.CYRILLIC or Script.DEVANAGARI +#: ever be added neither belongs here; their initials are real and +#: pinned as such ("А. С. Пушкин", "م. الفارسي"). +#: +#: Enumerated rather than derived from _SCRIPT_RANGES' keys: the +#: Script enum admits a member so that SOME behavior may key on it +#: (see Script), on assorted grounds -- KATAKANA is in it so the +#: classifier can name what it deliberately declines, and neither +#: DEFAULT_SCRIPT_ORDERS nor segment_scripts' default mentions it. +#: Membership therefore settles nothing about abbreviation: the four +#: coinciding today is what has been implemented, not a property of +#: the enum -- and Thai (#317) is an abugida too, so it must not +#: inherit this answer without someone deciding it. +_NO_INITIALS = (Script.HAN, Script.HANGUL, Script.HIRAGANA, + Script.KATAKANA) + def _script_matcher(*scripts: Script, whole: bool = False) -> Callable[[str], bool]: diff --git a/nameparser/_render.py b/nameparser/_render.py index ad236f6..702e8e7 100644 --- a/nameparser/_render.py +++ b/nameparser/_render.py @@ -38,6 +38,18 @@ # Ported verbatim from v1 (nameparser/config/regexes.py "initial", # minus the empty alternative) -- layering forbids importing the # pipeline here; keep in sync with _pipeline/_vocab.py by hand. +# Deliberately NOT composed with that module's repertoire test (#320): +# layering forbids the import, and nothing here needs it. The only use +# is v1's conjunction carve-out in _cap_word below, which this pattern +# can only reach once `normalized in lex.conjunctions` already holds -- +# and no CJK token reaches that, the shipped vocabulary carrying no CJK +# conjunction or particle in the default lexicon or in any locale pack. +# That is a property of the shipped DATA, not an invariant -- conjunctions +# is public, configurable API -- but the divergence stays harmless if a +# user adds one: CJK is caseless, so the carve-out's word.lower() and the +# fall-through's word.capitalize() return the same string either way. +# So the two copies keep identical PATTERNS and divergent PREDICATES; +# test_regex_sync pins the patterns, which is the promise being kept. _INITIAL = re.compile(r"^(\w\.|[A-Z])$") diff --git a/nameparser/_types.py b/nameparser/_types.py index 5b9a178..a4298a6 100644 --- a/nameparser/_types.py +++ b/nameparser/_types.py @@ -82,11 +82,15 @@ def __add__(self, other: object) -> NoReturn: # type: ignore[override] #: wherever it lands -- including a given-name "Van" -- so combine it #: with Role.FAMILY (as family_particles does) to get actual family #: particles; "conjunction" a joining word ("and", "y"); "initial" an -#: initial-shaped word ("J.", "Q"); +#: initial-shaped word in a script that HAS initials -- "J." or "А.", +#: never "씨." (#320); #: "joined" a continuation of the previous token within one merged #: piece ("Ph." + "D."), which the suffix view joins with a space #: instead of ", ". Every other tag is namespaced ("vocab:...") and is #: unstable debugging provenance -- never match against those. +#: This prose is the hand-maintained twin of docs/modules.rst's +#: STABLE_TAGS block; nothing pins the two against each other (the +#: test only compares the frozenset), so edit both or neither. STABLE_TAGS = frozenset({"particle", "conjunction", "initial", "joined"}) #: The one sanctioned view-reorder marker (namespaced = unstable API). diff --git a/tests/v2/cases.py b/tests/v2/cases.py index 48b619f..e01e859 100644 --- a/tests/v2/cases.py +++ b/tests/v2/cases.py @@ -773,6 +773,41 @@ def __post_init__(self) -> None: "Classification agrees with what classify does with " "the same token downstream -- 'V.' is a middle " "initial, not a post-nominal"), + Case("ja_honorific_with_a_period_no_comma", "田中さん 様.", + {"family": "田中", "suffix": "さん, 様."}, + classification="fix(#320)", + notes="the SPACED form, and the example _vocab.is_initial's " + "own docstring cites as what #320 cost. Same fields as " + "the comma row below, reached without a comma: the peel " + "scans segment 0 either way, and with '様.' no longer " + "vetoed the scan-back steps over it onto 田中さん and " + "peels さん. Worth its own row because the comma form " + "arrives through a different branch -- FAMILY_COMMA " + "flattens TWO runs before scanning, this one has a " + "single run -- so the comma row can stay green through " + "a change that breaks this one. 1.4.0 read this first " + "田中さん / last '様.', which is what 2.0 produced until " + "#320: parity before, a classified change after"), + Case("ja_honorific_period_does_not_stop_the_peel", "田中さん, 様.", + {"family": "田中", "suffix": "さん, 様."}, + classification="fix(#320)", + notes="#320's real cost: the veto did not merely misfile " + "'様.' -- it made _is_post_nominal say no, so the #312 " + "peel's scan-back stopped AT '様.' as the site instead " + "of stepping over it, found no listed tail there, and " + "abandoned the peel. One period and さん stayed glued " + "to 田中. Exactly the shape of " + "ja_honorific_glued_before_an_initial above, except " + "that here the token stopping the scan is a real " + "honorific rather than an initial, which is what makes " + "it a bug rather than the intended veto. The structure " + "is FAMILY_COMMA before and after -- SUFFIX_COMMA needs " + "more than one word ahead of the comma and 田中さん is " + "one -- so the two runs were always in the peel's " + "reach; only the strict test's answer moved. 1.4.0 read " + "this first '様.' / last 田中さん, which is exactly what " + "2.0 produced before this change -- the row sat at " + "parity until #320 moved it"), Case("ja_sama_glued", "山田太郎様", {"family": "山田太郎", "suffix": "様"}, classification="fix(#308)", @@ -785,6 +820,65 @@ def __post_init__(self) -> None: {"family": "김", "given": "민준", "suffix": "님"}, classification="fix(#308)", notes="the online/formal glued address form, 씨's twin"), + Case("ko_honorific_written_with_a_period", "김민준, 씨.", + {"family": "김민준", "suffix": "씨."}, + classification="fix(#320)", + notes="the period-written form of ko_honorific_after_comma " + "('김민준, 씨'), whose field ASSIGNMENT it must match " + "and before #320 did not -- same roles, the suffix " + "VALUE differing by the period it was written with. " + "_normalize strips the trailing period, " + "so the vocabulary sees 씨 either way -- the initial " + "veto was the only thing rejecting the written form, " + "and literally the veto: _is_suffix_piece is " + "'vocab:suffix' in tags and 'initial' not in tags, and " + "'씨.' carried both, so the suffix-shaped piece went to " + "the given. 1.4.0 read this first '씨.' / last 김민준 -- " + "the same fields 2.0 gave before this change, so the row " + "was at parity and #320 is what moves it"), + Case("ko_honorific_period_under_strict_comma_suffixes", "김민준, 씨.", + {"family": "김민준", "suffix": "씨."}, + policy=Policy(lenient_comma_suffixes=False), + classification="fix(#320)", + notes="the row above under the knob that governs exactly this " + "shape, and the table's only exercise of it. " + "lenient_comma_suffixes=False drops segment's post-comma " + "test to the strict one, so a 'Family, Suffix' input " + "whose suffix is INITIAL-SHAPED reads as a given-name " + "initial instead ('John Smith, V' -> given 'V'). '씨.' " + "is a single character plus a period, so it was in that " + "class by shape, and before #320 the knob decided it: " + "given '씨.' / family 김민준. It is out of the class now, " + "and the honorific parses identically under both " + "settings -- which is the claim this row exists to " + "hold, since the knob's own documentation scopes it to " + "the roman numerals 'I' and 'V' and _script_segment " + "names it as the setting that strands 씨 in a " + "neighbouring shape. No v1 spelling exists for the knob " + "(the facade runner skips this row), so the " + "classification compares against 1.4.0's single " + "reading, first '씨.' / last 김민준 -- the same fields " + "2.0 gave under EITHER setting before this change"), + Case("ko_honorific_with_a_period_no_comma", "김민준 씨.", + {"given": "민준", "family": "김", "suffix": "씨."}, + classification="fix(#320)", + notes="the period-written ko_honorific_ssi ('김민준 씨'), and " + "#320 by a different route than the row above: the veto " + "left '씨.' a NAME piece, and effective_script('씨.') is " + "None because the trailing period defeats the " + "wholly-one-script test, so script_orders declined for " + "the whole name and the three pieces fell back to " + "name_order -- given 김, middle 민준, family '씨.'. " + "Hangul segmentation is NOT what moves: it divides " + "김민준 identically either way, and only the order the " + "pieces are read in changes. 1.4.0 read this first " + "김민준 / last '씨.' -- undivided, no suffix. The only " + "row of the three that already differed from 1.4.0 " + "before this change, but it differed as given 김 / " + "middle 민준 / family '씨.'; the fields above are #320's, " + "not the segmenter's, so the row is classified to it -- " + "as ko_honorific_ssi is classified to #307 without " + "naming the same segmentation it also depends on"), Case("ko_honorific_glued_teacher", "김선생님", {"family": "김", "suffix": "선생님"}, classification="fix(#307)", @@ -1100,6 +1194,37 @@ def __post_init__(self) -> None: "Classified to #271, not parity: 1.4 gave first 양, " "last 미선, and it is the CJK order flip that swaps " "them"), + Case("ko_honorific_yang_trails", "김민준 양", + {"family": "김", "given": "민준", "suffix": "양"}, + classification="fix(#307)", + notes="the other side of ko_surname_yang_leads: the same " + "token trailing a name is 'Miss', and that is the whole " + "argument shipping it -- suffixes.py singles 양 out " + "(with 군) as the risk class it takes, a top-tier " + "surname admitted to the vocabulary on the strength of " + "position alone. Nothing pinned the trailing reading " + "before this row, so the leading rows carried the pair " + "by themselves. Classified to #307 like ko_honorific_ssi " + "(1.4 gave first 김민준, last 양; the recognition and " + "the order flip both move it) -- the point of the row " + "is the twin below"), + Case("ko_honorific_yang_written_with_a_period", "김민준 양.", + {"family": "김", "given": "민준", "suffix": "양."}, + classification="fix(#320)", + notes="the period-written twin, whose fields must equal the " + "row above and before #320 did not (given 김, middle " + "민준, family '양.' -- the veto kept '양.' a name piece, " + "exactly ko_honorific_with_a_period_no_comma's route). " + "The pair is the point: 양 is the shipped vocabulary's " + "acknowledged risk, so if the 양/군 policy is ever " + "tightened or withdrawn, both spellings have to move " + "together and neither row can be adjusted alone. 군 " + "gets no pair of its own -- it parses identically and " + "is the SAFER half (no surname reading), so it would " + "pin nothing these two do not. Classified to #320 like " + "its 씨 counterpart: 1.4.0 read this first 김민준 / last " + "'양.', and the fields above are the ones this change " + "produced, not the segmenter's"), Case("ko_surname_yang_leads_a_segmentable_given", "양 지훈", {"family": "양", "given": "지훈"}, classification="fix(#271)", diff --git a/tests/v2/pipeline/test_classify.py b/tests/v2/pipeline/test_classify.py index ef0ace1..69b7391 100644 --- a/tests/v2/pipeline/test_classify.py +++ b/tests/v2/pipeline/test_classify.py @@ -14,7 +14,11 @@ suffix_acronyms_ambiguous=frozenset({"ma"}), particles=frozenset({"de", "la", "van"}), particles_ambiguous=frozenset({"van"}), - conjunctions=frozenset({"and", "y"}), + # й is COPIED from the shipped conjunctions (Ukrainian, #267) so the + # collision test_cyrillic_initial_outranks_the_conjunction pins is + # one that really ships and the reader can check against the + # defaults. The copy is local: this file never reads the shipped set + conjunctions=frozenset({"and", "y", "й"}), bound_given_names=frozenset({"abdul"}), maiden_markers=frozenset({"née"}), ) @@ -55,6 +59,22 @@ def test_initial_tag() -> None: assert "initial" not in _tags(out, "John") +def test_cyrillic_initial_outranks_the_conjunction() -> None: + """#320 regression. 'й' is the Ukrainian conjunction (#267); 'Й.' + is an initial and must not be read as it. Narrowing is_initial to + [A-Za-z] -- the fix #320 originally proposed -- flips this token to + 'conjunction' and strips 'initial' off every Cyrillic, Greek, + Arabic, Hebrew, Devanagari and Armenian initial (the six + test_vocab.test_is_initial_script_repertoire asserts). Neither + moves field output on a short name, so this asserts the TAG. _LEX + copies й from the SHIPPED conjunctions so the collision is a real + one -- but the copy is local, so this test would keep passing if й + were dropped from the defaults.""" + out = _classified("Й. Сліпий") + assert "initial" in _tags(out, "Й.") + assert "conjunction" not in _tags(out, "Й.") + + def test_ambiguous_suffix_acronym_needs_periods() -> None: out = _classified("M.A. Ma") assert "vocab:suffix" in _tags(out, "M.A.") diff --git a/tests/v2/pipeline/test_vocab.py b/tests/v2/pipeline/test_vocab.py index b680eb8..b27a21f 100644 --- a/tests/v2/pipeline/test_vocab.py +++ b/tests/v2/pipeline/test_vocab.py @@ -2,10 +2,10 @@ from nameparser._lexicon import Lexicon from nameparser._pipeline._vocab import ( - effective_script, is_initial, is_suffix_lenient, + effective_script, is_initial, is_initial_shaped, is_suffix_lenient, is_suffix_strict, resolve_script_set, single_script, ) -from nameparser._policy import Script, _SCRIPT_RANGES +from nameparser._policy import Script, _NO_INITIALS, _SCRIPT_RANGES _LEX = Lexicon( suffix_acronyms=frozenset({"phd", "ma"}), @@ -22,6 +22,122 @@ def test_is_initial() -> None: assert not is_initial("b") # bare lowercase letter is not an initial +def test_is_initial_script_repertoire() -> None: + # An initial is a single LETTER standing in for a name. Alphabets + # have letters, so these are real initials ("А. С. Пушкин"). + assert is_initial("А.") # Cyrillic + assert is_initial("Α.") # Greek + assert is_initial("م.") # Arabic + assert is_initial("ה.") # Hebrew + assert is_initial("र.") # Devanagari + assert is_initial("Ա.") # Armenian + # Han ideographs, hangul syllables and kana are morphemes or + # syllables -- a single one never stands in for a name (#320). + assert not is_initial("씨.") + assert not is_initial("様.") + assert not is_initial("김.") + assert not is_initial("さ.") + assert not is_initial("ラ.") + # unchanged: a digit is ONE edge of \w's reach and '_' is another, + # and the shape half still owns both -- only the repertoire narrowed + assert is_initial("2.") + assert is_initial("_.") + # unchanged: the SHAPE half still requires a single character + assert not is_initial("राम.") + + +def test_is_initial_shaped_keeps_the_shape_half_reachable() -> None: + """The two halves are separately askable (#320): assign's + roman-numeral fork asks the SHAPE question about the piece before a + trailing 'V', and answering it with the narrowed predicate dropped + the family name out of 'John 씨. V' entirely.""" + for text in ("A.", "j.", "B", "2."): + assert is_initial_shaped(text) is is_initial(text) is True + for text in ("Jo", "b", "raam."): + assert is_initial_shaped(text) is is_initial(text) is False + # the whole difference between them, in both directions + for text in ("씨.", "様.", "김.", "さ.", "ラ."): + assert is_initial_shaped(text) and not is_initial(text) + + +def test_strict_suffix_veto_skips_cjk() -> None: + """#320: the initial veto is what stopped a period-written CJK + honorific being recognized. _normalize strips the trailing period, + so '씨.' reaches the vocabulary as '씨' -- the veto was the only + thing rejecting it.""" + lex = Lexicon(suffix_words=frozenset({"씨", "様"})) + assert is_suffix_strict("씨.", lex) + assert is_suffix_strict("様.", lex) + + +def _representative(script: Script) -> str: + """The first codepoint of `script`'s _SCRIPT_RANGES spans that the + SHAPE half admits as an initial. Shape-admitted, not simply the + first codepoint: a range's first codepoint is often unassigned or + punctuation (KATAKANA's 0x30A0 is a hyphen, HIRAGANA's 0x3040 is + unassigned), which \\w does not match -- and testing is_initial on + such a character answers False for the SHAPE's reason, making the + repertoire assertion below vacuously green. Raising when no span + holds one is the point rather than a corner: a script whose + declared ranges contain no initial-shaped character at all has + ranges that do not describe it.""" + for lo, hi in _SCRIPT_RANGES[script]: + for cp in range(lo, hi + 1): + if is_initial_shaped(chr(cp) + "."): + return chr(cp) + raise AssertionError( + f"no character in _SCRIPT_RANGES[{script}] is initial-SHAPED, " + f"so this script's declaration cannot be tested against " + f"is_initial -- check that the ranges are that script's") + + +def test_every_script_is_classified_for_initials() -> None: + """A member joining Script must be classified here on purpose; + _policy._NO_INITIALS carries the reasoning. + + The classification lives in this table rather than the assertion + being `set(Script) == set(_NO_INITIALS)`: that passes trivially + today, since all four current members are CJK, and the only way to + green it again after adding a script would be to declare that + script initial-less. That prejudges the answer. The point is to + force a decision, not a particular one. + + Three bindings, not two: the table covers Script, the table's + False rows are _NO_INITIALS, and -- the one that makes this a + behavioral test rather than a comparison of two constants -- each + row is checked against is_initial on a character DERIVED from that + script's own _SCRIPT_RANGES entry. Without the third, a script + declared initial-less under ranges that are not its own passes all + the way through while is_initial still says yes to its characters. + """ + has_initials = { + Script.HAN: False, # ideographs are morphemes + Script.HANGUL: False, # syllable blocks + Script.HIRAGANA: False, # syllables + Script.KATAKANA: False, # syllables + } + assert set(has_initials) == set(Script), ( + "a Script member is unclassified for initials: decide whether " + "a single character of it can stand in for a name, add the row, " + "and put it in _policy._NO_INITIALS if it cannot") + assert {s for s, yes in has_initials.items() if not yes} \ + == set(_NO_INITIALS), ( + "this table and _policy._NO_INITIALS disagree about which " + "scripts have initials: a row here saying False is what puts a " + "script in the constant, so add the missing member to " + "_NO_INITIALS -- or, if the constant is the one that's right, " + "flip the row") + for script, yes in has_initials.items(): + char = _representative(script) + assert is_initial(char + ".") is yes, ( + f"the declaration for {script} does not reach is_initial: " + f"the row says has_initials={yes}, but is_initial(" + f"{char + '.'!r}) -- on a character taken from " + f"_SCRIPT_RANGES[{script}] -- says {not yes}. Either the " + f"ranges are not this script's, or _NO_INITIALS and the " + f"repertoire predicate have come apart") + + def test_strict_suffix_initial_veto() -> None: assert is_suffix_strict("PhD", _LEX) assert not is_suffix_strict("V.", _LEX) # initial veto diff --git a/tools/differential/README.md b/tools/differential/README.md index 54cedaa..f683c0d 100644 --- a/tools/differential/README.md +++ b/tools/differential/README.md @@ -38,6 +38,58 @@ needs widening. The run must exit 0 before a 2.0 release; the classified summary it prints is the source for the "Behavior Changes" section of `docs/release_log.rst`. +## Do not put `python` in front of the worker + +`compare.py` spawns the worker by **script path**: + +``` +uv run --no-project tools/differential/worker_v1.py +``` + +Inserting `python` before the path -- +`uv run --no-project python tools/differential/worker_v1.py` -- makes +`python` the command and the script a mere argument, so `uv` never +reads the script's PEP 723 inline metadata and the `nameparser==1.4.*` +pin is never installed. With nothing to satisfy, `uv` runs the script +in the project's own `.venv`, where the working tree is installed +editable (`__editable__.nameparser-2.0.0.pth`) -- so the import +resolves to the checkout and **2.x answers every query while the +output is labelled 1.4.0**. Reproduced twice while working on #320. + +It is the same editable working tree that the missing-`--no-project` +case above lands on, by a different road. **`PYTHONSAFEPATH=1` does +not rescue it** -- that is the fix for the sibling CWD trap +(`AGENTS.md`, "Comparing against v1"), and reaching for it here is the +natural wrong turn, since a `.pth`-installed package is on `sys.path` +proper and safe-path never touches it. Measured: safe path on, still +2.0.0. `sys.path[0]` is the SCRIPT's directory +(`tools/differential/`), which contains no `nameparser` at all, so the +CWD is not the route either. Running the same command with an absolute +script path from a directory outside the project is the one variant +that does not lie: it raises `ModuleNotFoundError` instead. + +That is worse than an ordinary mistake, because of what the corrupted +output looks like. It is not garbage and it does not crash: it is +exactly the 2.x expected values, which is exactly what someone asking +"did 1.4.0 agree?" is hoping to see. Every diff vanishes, the run +comes out as parity, and the conclusion drawn is the precise opposite +of the truth. Same outcome as the missing `--no-project` above, and +the same reason both are written down here rather than left to the +reader to rediscover. + +So do not trust a 1.4 version number you did not make the worker +report. Establishing which library actually answered is cheap -- print +`nameparser.__version__` from **inside** the worker and check it +against the pin before comparing anything. Under this trap it prints +the checkout's version, which is the whole tell. + +One shell note while you are here: `compare.py | tail` swallows the +exit code under zsh. `$?` after a pipeline is `tail`'s status, and +`PIPESTATUS` is a bash array zsh does not define at all -- zsh's own +is the 1-indexed `pipestatus`, so `${PIPESTATUS[0]}` is the empty +string and a failing run reads as a passing one. Redirect to a file +and read the file instead of piping. + ## The three corpora `compare.py` reads **every** `corpus*.jsonl` beside it by default diff --git a/tools/differential/corpus_cjk.jsonl b/tools/differential/corpus_cjk.jsonl index 0d1d4d3..357a4d9 100644 --- a/tools/differential/corpus_cjk.jsonl +++ b/tools/differential/corpus_cjk.jsonl @@ -42,9 +42,11 @@ "田中さん" "田中さん II" "田中さん V." +"田中さん 様." "田中さん, PhD" "田中さん, V." "田中さん, 太郎" +"田中さん, 様." "田中博士" "諸葛亮" "阿明" @@ -63,7 +65,11 @@ "김민준 박사 씨" "김민준 박사님" "김민준 씨" +"김민준 씨." +"김민준 양" +"김민준 양." "김민준, 씨" +"김민준, 씨." "김민준님" "김민준박사님" "김민준씨"