From 268b47ef8de7a3342a3b3ec193877e9c190954f8 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Tue, 18 Aug 2026 03:17:21 -0700 Subject: [PATCH] feat(vocab): add "abd", and let a dual-membership word join MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "abd Allah Smith" read given 'abd', middle 'Allah' where "abdul Rahman Smith" reads given 'abdul Rahman'. The spellings that write the article as its own word -- Abd Allah, Abd al-Rahman -- match none of abdul/abdel/abdal, and the Arabic-script عبد has covered the same word since #269, so only the Latin side was short. Adding the word alone does not fix it, which is why this is not a one-line change. `abd` is also the postnominal ABD ("All But Dissertation") in SUFFIX_ACRONYMS, and the join's reserve counted every piece except titles and suffixes -- so the bound word's own piece did not count toward the three it needed, and the rule declined silently on the common three-word shape. The claimed piece now counts, which is what the reserve means: it asks whether enough OTHER words are left to spare, and this piece is not one of them to spare. Measured, the vocabulary-only alternatives each cost something this does not: moving `abd` to SUFFIX_ACRONYMS_AMBIGUOUS loses "Jane Smith, ABD", and dropping it from SUFFIX_ACRONYMS loses the postnominal outright. Keeping both readings costs nothing here -- position tells them apart, and all three credential spellings still parse. `abd` is the only shipped word in both sets (asserted by measurement, not assumption), so nothing else moves; all 751 differential corpus names are byte-identical and the harness reports 0 unexplained. Same shape as #397, where dual membership shrinks the rootname count P3's carve-out tests -- recorded at decisions.md#P5, with the general question left open on that issue. Co-Authored-By: Claude Opus 5 --- docs/design/decisions.md | 7 +++- docs/design/mechanisms.md | 1 + docs/design/rules.md | 14 ++++++- docs/release_log.rst | 2 + nameparser/_pipeline/_group.py | 12 +++++- nameparser/config/bound_given_names.py | 21 +++++++++- nameparser/config/suffixes.py | 6 +++ tests/test_bound_given_names.py | 57 ++++++++++++++++++++++++++ tests/v2/pipeline/test_group.py | 31 ++++++++++++++ tests/v2/pipeline/test_post_rules.py | 14 +++++++ 10 files changed, 160 insertions(+), 5 deletions(-) diff --git a/docs/design/decisions.md b/docs/design/decisions.md index 2c5ef538..b35ed312 100644 --- a/docs/design/decisions.md +++ b/docs/design/decisions.md @@ -310,10 +310,15 @@ Excluded (SUFFIX_ACRONYMS / SUFFIX_WORDS — the esq dual membership, deliberate - 2026-06-30 (first-name-prefix-join design; v1-era, carried into the v2 port) — the join is vocabulary-driven and deliberately tiny. +- 2026-08-18 — `abd` ADDED, and the reserve count corrected to let it work. It is the transliteration for the spellings that write the article as its own word ("Abd Allah", "Abd al-Rahman"), which abdul/abdel/abdal do not match; the Arabic-script عبد has covered the same word since #269, so only the Latin side was short. Not a C-ii collision, though it looks like one: C-ii settles a PRECEDENCE by frequency where neither reading is a name, and here one reading is a name and neither had to be given up. It is C-i's positional qualifier (2026-08-17) applied to a bound-given word — the naming use is LEADING, the credential use trailing, which is the `de` row of that table ('a TRAILING surname — no clash') rather than the `vd` row. `abd` is also the postnominal ABD ("All But Dissertation") in SUFFIX_ACRONYMS, and all three credential spellings still parse ("Jane Smith ABD", "Jane Smith, ABD", "Jane Smith A.B.D."). + What made it look impossible: adding the word alone changed nothing on the common three-word shape. The reserve count in the join gate excluded suffix-vocabulary pieces, and `abd` IS one, so its own piece did not count toward the three it needed — the rule declined silently. Counting the claimed piece as a name piece fixes it, and is right on its own terms: the reserve asks whether enough OTHER words are left to spare. Measured, the vocabulary-only alternatives both cost something the count fix does not — moving `abd` into SUFFIX_ACRONYMS_AMBIGUOUS loses "Jane Smith, ABD", and dropping it from SUFFIX_ACRONYMS loses the postnominal entirely. + Two limits, both measured and both accepted. The three-token "abd al rahman" still does not join — `al` is a particle and chains forward, which is the half of the original exclusion that survives. And position decides at the two ENDS only: in the given slot of a family-comma name the credential still wins, so "Smith, Abd" reports suffix 'Abd' with no given name where "Smith, Abdul" reports the given name, and no ambiguity is emitted for it. + Same shape as #397 (Catalan `i`, where dual membership shrinks the rootname count P3's carve-out tests), and the second instance the SAME DAY, #397 having been filed two and a half hours earlier. The general question — whether a word's membership in one set should suppress it from a count gating a rule keyed on another — is open there. + Excluded (BOUND_GIVEN_NAMES): - mohamad — a standalone given name in its own right; binding it would eat the middle name. -- abd — collides with the academic post-nominal "ABD", and the real form is the deferred multi-token "abd al rahman". +- abd — ADDED 2026-08-18, see the entry above; the exclusion stood on two reasons and only one of them survived. The collision with the post-nominal "ABD" turned out to cost nothing (position decides at both ends). The other reason stands as a known limit: the three-token spelling "abd al rahman" is still not joined, `al` being a particle that chains forward, so only the two-token "abd X" and the hyphenated "abd al-Rahman X" join. Excluded (DEFAULT_NICKNAME_DELIMITERS): diff --git a/docs/design/mechanisms.md b/docs/design/mechanisms.md index 4f35a661..61fa73ab 100644 --- a/docs/design/mechanisms.md +++ b/docs/design/mechanisms.md @@ -142,6 +142,7 @@ Contract statement. A workaround keyed to a third-party library's measured defec ### Field notes — the traps themselves - Enumerate the rules that BUILD a structure; do not recall them. #395's unit walk was written three times in one PR — P2's chain missing, then the conjunction and bound-given branches absorbing one token where the particle branch absorbed a unit, then the suffix stop — and each miss came from listing the joining rules from memory instead of reading them out of rules.md. Both later misses reproduced the very defect the first fix had just removed, mirrored. +- Re-exec a module in-process and its enums become NEW classes. Every `is` comparison against the ORIGINAL module's member then answers wrong and silently: `_group.py`'s `bound_join is not BoundJoin.DISABLED` compared across two `BoundJoin` classes and was always true, so a family comma's own segment joined when it must not, and a mutation sweep reported 3,150 phantom movers. Rebind the identity-compared names from the real module after the exec (`mod.__dict__["BoundJoin"] = real.BoundJoin`), and sanity-check one input the mutation must NOT move before believing any count. - Assert which tree you imported, on BOTH sides of a comparison. `python -c` puts CWD on sys.path; a script's own directory holds no nameparser in tools/differential/, so a stray PYTHONPATH outranks the editable install — measured: 89 diffs became 0, exit 0, both tell halves passing, because both sides had become the shadow. - Release notes describe intent; TAGS are ground truth. Before trusting a changelog cross-reference about what shipped where, run `git diff -- ` — the "1.2.1 pickle shim" claim recurred twice from a changelog line describing a thing that never shipped. - Never pipe a gate's output. Under zsh, `compare.py | tail` makes `$?` tail's status. Redirect to a file and read the file. diff --git a/docs/design/rules.md b/docs/design/rules.md index f86b837a..10732638 100644 --- a/docs/design/rules.md +++ b/docs/design/rules.md @@ -226,10 +226,20 @@ P4. Rationale: a particle links forward from inside a name; at the P5. Rationale: some given-name words are incomplete alone — "abdul" is a bound form that the next word completes. A recognized bound given-name word joins the word after it into - one given name. + one given name. It needs a name word to spare, so two name words + alone do not join — the second is the family name — except after + a family comma, where the family is already fixed. Where the word + is BOTH bound-given and suffix vocabulary, position decides and + both readings survive: leading, it is the bound word; trailing, + it is the suffix (S2). In the given slot after a family comma the + suffix reading wins. "abdul salam ahmed salem" → given="abdul salam" + "abd Allah Smith" → given="abd Allah" + "Salam, abd Allah" → given="abd Allah" + "abd Allah" → given="abd" + "Smith, Abd" → suffix="Abd" "mohamad ali smith" → given="mohamad" · boundary - history: decisions.md#P5 · implemented: nameparser/_pipeline/_group.py, nameparser/_pipeline/_post_rules.py + history: decisions.md#P5 · interacts: S2 · implemented: nameparser/_pipeline/_group.py, nameparser/_pipeline/_post_rules.py P6. Rationale: a particle ending the name has nothing to link forward to, so it is not doing a particle's work there. A diff --git a/docs/release_log.rst b/docs/release_log.rst index 70450955..805ef280 100644 --- a/docs/release_log.rst +++ b/docs/release_log.rst @@ -32,6 +32,8 @@ Release Log - Fix a name opening with a particle that is *never* a given name being split at the particle under a family-first name order -- ``Policy(name_order=FAMILY_FIRST)`` and ``Policy(name_order=FAMILY_FIRST_GIVEN_LAST)`` alike, and identically: ``"de Mesnil"`` read as family ``de``, given ``Mesnil``, and ``"de la Vega"`` as family ``de``, given ``la Vega``. Each is now the whole surname, as it has always been in the default order. The rule enforcing it asked for the particle by the ``GIVEN`` role, which under a family-first order belongs to the token *after* the particle, so the test read the wrong word and declined. It now also asks by position -- the piece that opens the name -- so both shapes of the same rule are caught: where such a particle stands alone as a piece, either opening the name or in the given position, the name is left with no given name at all, the given and the middles folding into the family. Standing *alone* is the whole of it, and the rule claims nothing wider: ``"Juan de la Vega"`` under ``FAMILY_FIRST`` still reports given ``de la Vega``, because there the particle chained onto the words after it rather than standing alone, and a bare ``"de"`` with nothing to fold into is still reported as the given name. The decision behind the fix: a word that can never be a given name leaves ``name_order`` nothing to decide, so declaring family-first is not a reason to make ``de`` a surname on its own. A leading particle that *may* be a given name is genuinely order-dependent and is untouched -- ``"van Gogh"`` still reads as family ``van``, given ``Gogh`` under both family-first orders. This is also what gives ``Lexicon.particles_ambiguous`` an effect outside the default order: taking a word out of it now changes the parsed fields under a family-first order, where before it moved only the ambiguity report. Seven of the 751 differential corpus names move, the same seven under each family-first order; default-order output is byte-identical over all 751, at the 1.4.0, 2.0.0 and 2.1.0 differential baselines alike (closes #359) + - Add ``abd`` to :data:`~nameparser.config.bound_given_names.BOUND_GIVEN_NAMES`, so the spellings that write the article as its own word join like the others do: ``"abd Allah Smith"`` was given ``abd``, middle ``Allah`` and is now given ``abd Allah``. ``abdul``, ``abdel`` and ``abdal`` were already there, and the Arabic-script ``عبد`` has covered the same word since 2.0, so only the Latin spelling was short. The word is also the postnominal ABD ("All But Dissertation") and stays in ``SUFFIX_ACRONYMS``: position tells the two readings apart, so ``"Jane Smith ABD"``, ``"Jane Smith, ABD"`` and ``"Jane Smith A.B.D."`` all still read the credential as a suffix. Making that work needed one fix beyond the wordlist. The join reserves enough words to leave a family name behind, and the reserve counted every piece except titles and suffixes -- so a word in BOTH vocabularies did not count as a name word even when the rule had already claimed it as one, and the join declined silently on the common three-word shape. The claimed piece now counts, which is what the reserve means: it asks whether enough OTHER words are left to spare. No shipped word other than ``abd`` is in both sets, and for any word that is not, the changed expression reduces to the old one -- so nothing else can move, by arithmetic rather than by sampling. The differential corpus agrees at all 751 names, but it holds no name carrying a dual-membership word, so that agreement tests the corpus rather than the change (#400) + - Change how far a leading never-given particle takes the surname when a family-first ``name_order`` is declared. ``Policy(name_order=FAMILY_FIRST)`` read ``"de Mesnil Juan"`` as family ``de Mesnil Juan`` -- the whole name -- and now reads family ``de Mesnil``, given ``Juan``. Declaring a family-first order asserts that what follows the family is not more surname, and where the surname run ends is exactly that question, so the declaration settles it. The default order is unchanged, deliberately: with no order declared nothing marks where the surname ends, and a particle followed by several words really can be all surname -- ``von Bergen Wessels`` is one such name. Nothing in ``"de Mesnil Juan"`` distinguishes it from that reading except a declared order or a comma. A caller who means family ``de la Vega`` plus given ``Juan`` in the default order writes the comma, which already parses that way. The run takes one name WORD rather than one token: a conjunction-joined run and a bound given-name pair each count once, so the stop cannot land inside one -- ``"de la Vega y Santos Juan"`` reads family ``de la Vega y Santos``, and ``"ibn Awf abdul Rahman"`` reads given ``abdul Rahman``. Where two or more words are left over the two family-first orders differ from each other for the first time: ``"de la Cruz Juan Carlos"`` reads given ``Juan``, middle ``Carlos`` under ``FAMILY_FIRST`` and middle ``Juan``, given ``Carlos`` under ``FAMILY_FIRST_GIVEN_LAST``. An ambiguous leading particle is untouched in every order -- ``"van Gogh Jan Pieter"`` still reads family ``van`` under both family-first orders -- and so is a family comma, where the comma has already fixed the surname (``"Smith, de Mesnil"`` keeps family ``Smith de Mesnil``). One of the 751 differential corpus names moves, ``"de Mesnil Garcia"`` to family ``de Mesnil``, given ``Garcia``, under each family-first order; default-order output is byte-identical over all 751. This reverses the answer #364 was closed on, and the reasoning is recorded at ``docs/design/decisions.md#P1`` (closes #395) - Change the ``detail`` text of a ``PARTICLE_OR_GIVEN`` ambiguity to name the role the leading particle was actually given. It said "read as a given name" under every ``name_order``, which is false under ``Policy(name_order=FAMILY_FIRST)`` -- there ``"Van Johnson"`` reads as family ``Van``, given ``Johnson``, and the report described the reading not taken. It now ends "read as a family name" in that case, reading the role off the assigned token the way ``SUFFIX_OR_NAME`` already did -- that kind names both parts (``read as a family name rather than a post-nominal``), while this one names only the part it took. The ``kind`` is unchanged and stays ``PARTICLE_OR_GIVEN``: the fork really is particle-or-given, and only the human-readable text moved. Default-order output is identical (#355) diff --git a/nameparser/_pipeline/_group.py b/nameparser/_pipeline/_group.py index 399c87e5..70da1d15 100644 --- a/nameparser/_pipeline/_group.py +++ b/nameparser/_pipeline/_group.py @@ -342,8 +342,18 @@ def merge(lo: int, hi: int, add: Set[str] = frozenset(), and len(pieces[first_name_k]) == 1 and "vocab:bound-given" in tokens[pieces[first_name_k][0]].tags): + # first_name_k counts as a name piece even when it is + # ALSO suffix vocabulary. The reserve asks whether enough + # OTHER words are left to spare, and this piece is the one + # the rule has already claimed as a name -- excluding it + # made a dual-membership word silently un-joinable -- + # found while adding 'abd' ("All But Dissertation" as well + # as عبد), which this had to be fixed for, though it is + # not why the word was excluded. Same shape as the count + # #397 describes. non_suffix = sum(1 for k in range(len(pieces)) - if not title(k) and not suffix(k)) + if not title(k) + and (k == first_name_k or not suffix(k))) if non_suffix >= bound_join: merge(first_name_k, first_name_k + 2) return pieces, ptags diff --git a/nameparser/config/bound_given_names.py b/nameparser/config/bound_given_names.py index f9ca89a6..fb11becd 100644 --- a/nameparser/config/bound_given_names.py +++ b/nameparser/config/bound_given_names.py @@ -6,7 +6,10 @@ #: rule on the FIRST non-title piece, so it is not about roles -- it #: fires whatever name_order later assigns. It reserves a piece for what #: follows: three pieces that are neither title nor suffix in a main -#: segment, which is why two-word "abdul salam" stays given "abdul" plus +#: segment -- counting the bound word's OWN piece even where that word is +#: also suffix vocabulary, since it is the piece the rule has claimed +#: rather than one left to spare -- which is why two-word "abdul salam" +#: stays given "abdul" plus #: family "salam"; only two after a family comma, where the family name #: is already fixed ("salam, abdul rahman" → given "abdul rahman"). #: Mirrors :py:data:`~nameparser.config.particles.PARTICLES`, which @@ -15,6 +18,22 @@ 'abdul', 'abdel', 'abdal', + # The bare transliteration, which abdul/abdel/abdal do not match: + # "abd Allah Smith" -> given "abd Allah", and "Abd al-Rahman + # Smith" likewise, al-Rahman being ONE token. The three-token + # spelling "abd al rahman smith" is still not joined -- `al` is a + # particle and chains forward -- and stays deferred, as it was + # when this word was excluded. Same word as عبد below, which has + # covered the Arabic-script side since #269 (shipped in 2.0). + # + # Collides with the postnominal ABD ("All But Dissertation"), + # which stays in SUFFIX_ACRONYMS. Position decides at the two ends + # -- a leading `abd` reads as a name, a trailing one as the + # credential -- but not everywhere: in the given slot of a + # family-comma name the credential still wins, so "Smith, Abd" + # reports suffix 'Abd' and no given name, where "Smith, Abdul" + # reports the given name. Recorded at decisions.md#P5. + 'abd', 'abu', 'abou', 'umm', diff --git a/nameparser/config/suffixes.py b/nameparser/config/suffixes.py index f71bdb16..d2b21bec 100644 --- a/nameparser/config/suffixes.py +++ b/nameparser/config/suffixes.py @@ -204,6 +204,12 @@ 'aas', 'aba', 'abc', + # "All But Dissertation". Also the Latin transliteration of the + # Arabic bound given-name word in BOUND_GIVEN_NAMES (whose + # Arabic-script counterpart عبد is in that set only) -- the one + # word in both sets, kept in both deliberately. Position decides + # at the two ends but not in a family comma's given slot; see + # decisions.md#P5. 'abd', 'abpp', 'abr', diff --git a/tests/test_bound_given_names.py b/tests/test_bound_given_names.py index 43bf8605..d155de20 100644 --- a/tests/test_bound_given_names.py +++ b/tests/test_bound_given_names.py @@ -147,3 +147,60 @@ def test_opt_out_via_clear(self) -> None: self.m(hn.middle, "salam ahmed", hn) self.m(hn.last, "salem", hn) + # --- 'abd', which is also the postnominal ABD --- + def test_abd_joins_the_word_after_it(self) -> None: + """The spelling that writes the article separately.""" + hn = HumanName("abd Allah Smith") + self.m(hn.first, "abd Allah", hn) + self.m(hn.last, "Smith", hn) + + def test_abd_joins_after_a_family_comma(self) -> None: + hn = HumanName("Smith, abd Allah") + self.m(hn.first, "abd Allah", hn) + self.m(hn.last, "Smith", hn) + + def test_abd_pairwise_like_abdul(self) -> None: + """Joins ONCE: 'Rahman' pairs, 'Ahmed' stays a middle name.""" + hn = HumanName("abd Rahman Ahmed Salem") + self.m(hn.first, "abd Rahman", hn) + self.m(hn.middle, "Ahmed", hn) + self.m(hn.last, "Salem", hn) + + def test_abd_joins_with_a_suffix_present(self) -> None: + """The line this fix changes is a SUFFIX count, so the shapes + that carry a real suffix piece are the ones it governs.""" + for text, suffix in (("abd Allah Smith jr", "jr"), + ("abd Allah Smith PhD", "PhD"), + ("abd Allah Smith, PhD", "PhD"), + ("abd Allah Smith III", "III")): + hn = HumanName(text) + self.m(hn.first, "abd Allah", hn) + self.m(hn.middle, "", hn) + self.m(hn.last, "Smith", hn) + self.m(hn.suffix, suffix, hn) + + def test_both_readings_of_abd_in_one_name(self) -> None: + """Position tells them apart -- the claim the collision rests + on, in a single string: bound given name in front, postnominal + behind.""" + hn = HumanName("abd Allah Smith ABD") + self.m(hn.first, "abd Allah", hn) + self.m(hn.last, "Smith", hn) + self.m(hn.suffix, "ABD", hn) + + def test_abd_joins_behind_a_title(self) -> None: + hn = HumanName("Dr. abd Allah Smith") + self.m(hn.title, "Dr.", hn) + self.m(hn.first, "abd Allah", hn) + self.m(hn.last, "Smith", hn) + + def test_the_abd_postnominal_still_reads_as_a_suffix(self) -> None: + """The collision: ABD is also All But Dissertation. Position + tells the two apart, so neither reading had to be given up.""" + for text in ("Jane Smith ABD", "Jane Smith, ABD", + "Jane Smith A.B.D."): + hn = HumanName(text) + self.m(hn.first, "Jane", hn) + self.m(hn.last, "Smith", hn) + self.assertTrue(hn.suffix.upper().replace(".", "") == "ABD", + f"{text}: suffix={hn.suffix!r}") diff --git a/tests/v2/pipeline/test_group.py b/tests/v2/pipeline/test_group.py index 6140ea46..e892139c 100644 --- a/tests/v2/pipeline/test_group.py +++ b/tests/v2/pipeline/test_group.py @@ -292,3 +292,34 @@ def test_suffix_comma_name_segment_gets_no_additional_count() -> None: # ', PhD' must not tip the single-letter-conjunction carve-out out = _grouped("John y Smith, PhD") assert _piece_texts(out) == [["John", "y", "Smith"], ["PhD"]] + + +_DUAL_LEX = dataclasses.replace( + _LEX, bound_given_names=frozenset({"abdul", "dual"}), + suffix_acronyms=frozenset({"phd", "dual"})) + + +def test_a_bound_word_in_two_vocabularies_still_joins() -> None: + # The reserve counts the pieces left to spare, and the bound word's + # own piece is not one of them to spare -- it is the piece the rule + # has already claimed. Counting it as a suffix, because the same + # word is ALSO suffix vocabulary, left the rule silently unable to + # fire: three name words, but only two counted, one short of the + # threshold. This is what kept 'abd' out of BOUND_GIVEN_NAMES; the + # shipped word is exercised in tests/test_bound_given_names.py. + # Synthetic word, so this pins the MECHANISM -- 'abd' is the only + # shipped member of the intersection, and removing it from the + # vocabulary leaves this test green. + out = _grouped("dual Allah Smith", lexicon=_DUAL_LEX) + assert _piece_texts(out) == [["dual Allah", "Smith"]] + + +def test_the_reserve_still_declines_when_only_a_suffix_is_left() -> None: + # The counted piece is the bound word's own, and NOTHING else that + # a suffix check excludes: with 'jr' behind it there is no family + # name to spare, so the join must still decline. The tempting + # simpler repair -- count every non-title piece -- passes the test + # above and fails this one, joining 'dual Allah' and leaving the + # name with no family at all. + out = _grouped("dual Allah jr", lexicon=_DUAL_LEX) + assert _piece_texts(out) == [["dual", "Allah", "jr"]] diff --git a/tests/v2/pipeline/test_post_rules.py b/tests/v2/pipeline/test_post_rules.py index eff38dfd..8678ddf9 100644 --- a/tests/v2/pipeline/test_post_rules.py +++ b/tests/v2/pipeline/test_post_rules.py @@ -581,3 +581,17 @@ def test_a_suffix_word_mid_run_ends_the_chain( assert _by_role(out, Role.FAMILY) == "de Mesnil" assert _by_role(out, Role.GIVEN) == given assert _by_role(out, Role.MIDDLE) == middle + + +@pytest.mark.parametrize("policy", _FAMILY_FIRST) +def test_a_bound_pair_survives_the_leading_particle_stop( + policy: Policy) -> None: + # The stop leaves the bound pair whole (rules.md#P5 via _units). + # Asserted under a family-first order deliberately: in the DEFAULT + # order the fold takes the whole name into the family, so the join + # is invisible there and the assertion would pass with the join + # broken. + out = run(ParseState(original="de Mesnil abd Allah", + lexicon=Lexicon.default(), policy=policy)) + assert _by_role(out, Role.FAMILY) == "de Mesnil" + assert _by_role(out, Role.GIVEN) == "abd Allah"