From 7b85fd9246c631089a73f93c7b728090f1cf12af Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Sun, 13 Sep 2026 12:16:20 -0700 Subject: [PATCH 1/6] feat(vocabulary): conjunctions_ambiguous, the single-letter connectives that read as initials A new Lexicon marker subset of `conjunctions`, default {"e"}, plus the AmbiguityKind member that reports it. No emitter yet -- the classify fork lands in the next commit, so the contract trigger is None under a strict xfail until it does. The shim follows honorific_tails rather than the two v1-exposed subsets: there is no v1 manager for this field, so _build_snapshot() intersects CONJUNCTIONS_AMBIGUOUS with the v1 conjunction set and deleting the base word is what turns the marking off. The pair is deliberately NOT registered in _SUBSET_FIELDS: an orphan there is inert, and AGENTS.md's invariants rule guards harm, not no-ops (given_name_titles precedent). Refs #383, #479 Co-Authored-By: Claude Fable 5.1 --- nameparser/_config_shim.py | 12 ++++++++- nameparser/_lexicon.py | 31 ++++++++++++++++++----- nameparser/_types.py | 21 ++++++++++++++++ nameparser/config/conjunctions.py | 41 +++++++++++++++++++++++++++++++ tests/v2/test_config_shim.py | 21 ++++++++++++++++ tests/v2/test_contracts.py | 8 ++++++ tests/v2/test_lexicon.py | 15 ++++++++++- 7 files changed, 141 insertions(+), 8 deletions(-) diff --git a/nameparser/_config_shim.py b/nameparser/_config_shim.py index 1bebd65e..1f3ac6e6 100644 --- a/nameparser/_config_shim.py +++ b/nameparser/_config_shim.py @@ -989,11 +989,13 @@ def _build_snapshot(self) -> tuple[Lexicon, Policy, _RenderDefaults]: singleton -- only direct attribute mutation is on the 3.0 removal path. """ + from nameparser.config.conjunctions import CONJUNCTIONS_AMBIGUOUS from nameparser.config.maiden_markers import MAIDEN_MARKERS from nameparser.config.suffixes import GLUED_HONORIFICS from nameparser.config.surnames import KOREAN_SURNAMES acronyms = frozenset(self.suffix_acronyms) particles = frozenset(self.prefixes) + conjunctions = frozenset(self.conjunctions) bound = frozenset(self.bound_first_names) ambiguous_acronyms = frozenset(self.suffix_acronyms_ambiguous) & acronyms # Drop any ambiguous acronym from the word set rather than the @@ -1062,7 +1064,15 @@ def _build_snapshot(self) -> tuple[Lexicon, Policy, _RenderDefaults]: particles_ambiguous=( particles - frozenset(self.non_first_name_prefixes)) | (bound & particles), - conjunctions=frozenset(self.conjunctions), + conjunctions=conjunctions, + # no v1 manager of its own: the ambiguous-connective + # subset is 2.4 behavior (#383/#479), so it rides in the + # snapshot only. Intersect with the conjunction set, the + # same rule honorific_tails gets against suffix_words below: + # Lexicon enforces the subset, and v1 semantics are that + # deleting the base word turns the behavior off -- a + # lingering marker simply stops mattering. + conjunctions_ambiguous=CONJUNCTIONS_AMBIGUOUS & conjunctions, bound_given_names=bound, # v1 Constants has no manager for these (#274 is 2.0 # behavior); the data module is the only source diff --git a/nameparser/_lexicon.py b/nameparser/_lexicon.py index 72be2519..57b99193 100644 --- a/nameparser/_lexicon.py +++ b/nameparser/_lexicon.py @@ -24,8 +24,8 @@ _VOCAB_FIELDS = ( "titles", "given_name_titles", "suffix_acronyms", "suffix_words", "suffix_acronyms_ambiguous", "particles", "particles_ambiguous", - "conjunctions", "bound_given_names", "maiden_markers", "surnames", - "honorific_tails", + "conjunctions", "conjunctions_ambiguous", "bound_given_names", + "maiden_markers", "surnames", "honorific_tails", ) #: (marker, base, why) triples. Each marker QUALIFIES how entries of @@ -63,9 +63,12 @@ #: SHIPPED vocabulary to the invariant, as it is the only thing that #: ever held a caller's own. #: -#: given_name_titles is deliberately NOT here and has no check of its -#: own -- see the note in __post_init__ for why every attempt at one -#: rejected working configurations. +#: Two fields are deliberately NOT here and have no check of their own. +#: given_name_titles -- see the note in __post_init__ for why every +#: attempt at one rejected working configurations. conjunctions_ambiguous +#: -- an orphan is never consulted because the classify fork tests +#: conjunctions first, so it is inert rather than harmful, and +#: AGENTS.md's invariants rule guards harm, not no-ops. _SUBSET_FIELDS = ( ("particles_ambiguous", "particles", "an orphan emits a spurious particle-or-given ambiguity"), @@ -509,6 +512,20 @@ class Lexicon: #: ("and", "&", "y", "и", ...). Full default list: #: :data:`~nameparser.config.conjunctions.CONJUNCTIONS`. conjunctions: frozenset[str] = frozenset() + #: Subset of conjunctions that read as an INITIAL rather than a + #: connective in a name written wholly in one case ("e": "jose e + #: maria santos" reads middle "e maria", where "juan garcia y + #: lopez" joins because "y" is not a member). Mixed-case input is + #: decided by the writing instead and never consults this set, and + #: neither does a caseless letter (Arabic و), which has no case to + #: read. A member additionally reports + #: :attr:`~nameparser.AmbiguityKind.CONJUNCTION_OR_INITIAL`; a + #: non-member reports nothing, its reading not being in doubt. + #: Full default list: + #: :data:`~nameparser.config.conjunctions.CONJUNCTIONS_AMBIGUOUS`. + #: Entries need not also be in ``conjunctions``; one that is not is + #: simply never consulted, so it is inert rather than an error. + conjunctions_ambiguous: frozenset[str] = frozenset() #: Given-name prefixes that bind to the following word to form one #: given name ("abdul" -> "Abdul Salam"); never standalone names. #: Full default list: @@ -786,7 +803,8 @@ def _default_lexicon() -> Lexicon: # v1 data modules are the single source of vocabulary through 2.x. from nameparser.config.bound_given_names import BOUND_GIVEN_NAMES from nameparser.config.capitalization import CAPITALIZATION_EXCEPTIONS - from nameparser.config.conjunctions import CONJUNCTIONS + from nameparser.config.conjunctions import ( + CONJUNCTIONS, CONJUNCTIONS_AMBIGUOUS) from nameparser.config.maiden_markers import MAIDEN_MARKERS from nameparser.config.particles import NON_GIVEN_NAME_PARTICLES, PARTICLES from nameparser.config.suffixes import ( @@ -817,6 +835,7 @@ def _default_lexicon() -> Lexicon: # may-be-given subset (migration: complement translation). particles_ambiguous=PARTICLES - NON_GIVEN_NAME_PARTICLES, conjunctions=CONJUNCTIONS, + conjunctions_ambiguous=CONJUNCTIONS_AMBIGUOUS, bound_given_names=BOUND_GIVEN_NAMES, maiden_markers=MAIDEN_MARKERS, surnames=KOREAN_SURNAMES, diff --git a/nameparser/_types.py b/nameparser/_types.py index e4cc6eba..d7543d2b 100644 --- a/nameparser/_types.py +++ b/nameparser/_types.py @@ -450,6 +450,27 @@ class AmbiguityKind(StrEnum): #: is the family and nothing about this, so the fork is real and #: ``detail`` names the word it turned on. PARTICLE_OR_GIVEN = "particle-or-given" + #: A single-letter connective in a name written wholly in ONE case + #: -- all upper or all lower alike -- where the writing therefore + #: says nothing about which reading was meant. The letter is read as + #: an INITIAL and this reports the fork: "jose e maria santos" gives + #: middle "e maria" and "JOSE E MARIA SANTOS" middle "E MARIA", + #: both flagged. Only a letter the vocabulary marks both ways + #: reports -- ``Lexicon.conjunctions_ambiguous``, "e" by default -- + #: because a letter outside it is not in doubt: "JUAN GARCIA Y + #: LOPEZ" joins into family "GARCIA Y LOPEZ" and reports nothing, + #: as does the Cyrillic "ХОСЕ И МАРИЯ САНТОС". + #: Three things never reach the fork. MIXED-case input decides on + #: the writing instead, so "Jose E Maria Santos" reads the capital + #: as an initial and "John e Smith" the lowercase letter as the + #: connective, neither reporting. A CASELESS letter has no case to + #: read, so Arabic "محمد و علي" keeps its connective silently. And a + #: MULTI-letter connective ("and", "та", "και") or a symbol ("&") is + #: no initial's shape at any casing. + #: ``detail`` names the token, the kind naming neither the field nor + #: the letter: which field the reading lands in follows the name's + #: shape and its ``name_order``, the PARTICLE_OR_GIVEN precedent. + CONJUNCTION_OR_INITIAL = "conjunction-or-initial" #: A name of one name word that nothing else decided had to be read #: as one field or the other, and both readings fit it equally well #: -- "Andrew", "Smith". The convention picks the given name under diff --git a/nameparser/config/conjunctions.py b/nameparser/config/conjunctions.py index 21a1c1ef..1b5a03c6 100644 --- a/nameparser/config/conjunctions.py +++ b/nameparser/config/conjunctions.py @@ -33,5 +33,46 @@ e.g. "President of the United States". """ +CONJUNCTIONS_AMBIGUOUS = frozenset({ + # #383/#479: single letters that read as an INITIAL rather than a + # connective when the input carries no case evidence -- a name + # written wholly in one case, upper or lower alike. A letter + # outside this set joins there, bare capital included. + # + # 'e' and not 'y', measured: a bare E initial is common (Edward, + # Elizabeth) and an 'e' between two surnames is rare outside couple + # listings; a bare Y initial is rare and 'y' between two surnames is + # the commonest Hispanic compound and this library's oldest fixture + # ('Velasquez y Garcia'). Cyrillic и/і/й follow y, not e: #267 + # blessed their joining and nothing here narrows it. + # + # 'i' (Catalan) is NOT here because it is not conjunction vocabulary + # at all yet; it ships in this subset when #397 adds it, a bare I + # initial being as common as a bare E. + # + # A caller edits the policy rather than a switch: remove 'e' to + # restore joining for Portuguese data, add 'y' for a Dutch-style + # "every single letter is an initial". + 'e', +}) +""" +Single-letter entries of :data:`CONJUNCTIONS` that read as an initial, +not a connective, in a name written wholly in one case. +""" + assert_normalized("CONJUNCTIONS", CONJUNCTIONS) + +# Guard the invariant the docstring promises, so a future edit that +# breaks it fails at import time (same rationale as suffixes.py). Note +# `assert` is stripped under `python -O`; Lexicon re-checks the +# relationship at construction, which is what protects a caller's own +# vocabulary. +assert CONJUNCTIONS_AMBIGUOUS <= CONJUNCTIONS, \ + "CONJUNCTIONS_AMBIGUOUS must stay a subset of CONJUNCTIONS" +assert all(len(w) == 1 and w.upper() != w.lower() + for w in CONJUNCTIONS_AMBIGUOUS), \ + "CONJUNCTIONS_AMBIGUOUS holds CASED single letters; the fork only " \ + "reads a cased single-letter token, so a caseless letter (Arabic " \ + "و) or a multi-letter entry would be silently inert" +assert_normalized("CONJUNCTIONS_AMBIGUOUS", CONJUNCTIONS_AMBIGUOUS) diff --git a/tests/v2/test_config_shim.py b/tests/v2/test_config_shim.py index 6bd41aea..f6f53c8f 100644 --- a/tests/v2/test_config_shim.py +++ b/tests/v2/test_config_shim.py @@ -499,6 +499,27 @@ def test_snapshot_removing_a_honorific_word_turns_the_peel_off() -> None: assert (name.first, name.last, name.suffix) == ("민준씨", "김", "") +def test_snapshot_removing_a_conjunction_turns_the_marker_off() -> None: + # The deciding case for conjunctions_ambiguous, which has no v1 + # manager of its own: the snapshot intersects CONJUNCTIONS_AMBIGUOUS + # with the v1 conjunction set, so deleting 'e' from the one v1 knob + # that reaches it must make the marking stop mattering rather than + # raise the subset error or leave 'e' reading as an initial on a + # word the parse no longer treats as a connective at all. With the + # default config the intersection is a no-op, so the default-equality + # test above pins nothing here. + c = Constants() + assert HumanName("john e smith", constants=c).middle == "e" # baseline + c.conjunctions.remove("e") + lexicon, _, _ = c._snapshot() # must not raise + assert "e" not in lexicon.conjunctions_ambiguous + # 'e' is no vocabulary at all now: a bare lowercase letter is not + # initial-shaped, so it is an ordinary middle name word + name = HumanName("john e smith", constants=c) + assert (name.first, name.middle, name.last) == ("john", "e", "smith") + assert name.initials() == "j. e. s." + + def test_snapshot_field_translation() -> None: c = Constants() lexicon, policy, defaults = c._snapshot() diff --git a/tests/v2/test_contracts.py b/tests/v2/test_contracts.py index fb05ee20..bd46a38c 100644 --- a/tests/v2/test_contracts.py +++ b/tests/v2/test_contracts.py @@ -9,6 +9,9 @@ _AMBIGUITY_TRIGGERS: dict[AmbiguityKind, str | None] = { AmbiguityKind.PARTICLE_OR_GIVEN: "Van Johnson", + # no emitter yet -- arrives with the classify fork in this same PR + # (#383/#479); flipped to "JOSE E MARIA SANTOS" there + AmbiguityKind.CONJUNCTION_OR_INITIAL: None, AmbiguityKind.UNBALANCED_DELIMITER: 'Jon "Nick Smith', AmbiguityKind.COMMA_STRUCTURE: "Smith, John, Extra, Jr.", AmbiguityKind.SUFFIX_OR_NICKNAME: "JEFFREY (JD) BRICKEN", @@ -241,3 +244,8 @@ def test_the_documented_replacements_for_an_in_place_edit_work() -> None: # recipe 2: an extended Lexicon for the 2.0 API parser = Parser(lexicon=Lexicon.default().add(titles={"dean"})) assert parser.parse("Dean Smith").title == "Dean" + + +def test_conjunction_or_initial_is_a_stable_string() -> None: + # A StrEnum member IS its value; the value is API and never changes. + assert AmbiguityKind.CONJUNCTION_OR_INITIAL == "conjunction-or-initial" diff --git a/tests/v2/test_lexicon.py b/tests/v2/test_lexicon.py index 241d0708..afaffc99 100644 --- a/tests/v2/test_lexicon.py +++ b/tests/v2/test_lexicon.py @@ -28,6 +28,9 @@ def test_default_sources_v1_vocabulary() -> None: # flipped model: 'dos' is never-given in v1, so NOT ambiguous here assert "dos" in lex.particles and "dos" not in lex.particles_ambiguous assert "van" in lex.particles_ambiguous + # the policy is 'e' and not 'y' (decisions.md#P3): a bare E initial is + # common, where 'y' between two surnames is the commonest Hispanic compound + assert "e" in lex.conjunctions_ambiguous and "y" not in lex.conjunctions_ambiguous # v1's CAPITALIZATION_EXCEPTIONS maps 'phd' -> 'Ph.D.' (verbatim, not # normalized -- only keys are lowercased/period-stripped at # construction, values pass through unchanged). @@ -421,6 +424,16 @@ def test_removing_a_title_leaves_its_given_name_marker_alone() -> None: assert lean.given_name_titles == frozenset({"sheikh"}) +def test_removing_a_conjunction_leaves_its_ambiguous_marker_alone() -> None: + # conjunctions_ambiguous is the same shape as given_name_titles: not + # in _SUBSET_FIELDS, so an orphan is inert rather than rejected -- + # the marker is never consulted once its base entry is gone + # (decisions.md#P3). + lean = Lexicon.default().remove(conjunctions={"e"}) + assert "e" not in lean.conjunctions + assert "e" in lean.conjunctions_ambiguous + + @pytest.mark.parametrize("word", [ "dr.", " Dr. ", ". a .", ". .", "..x..", " .b. ", # the three non-ASCII full stops (#322), alone and mixed with @@ -501,7 +514,7 @@ def test_every_shipped_entry_is_already_nfc() -> None: # authored. A data module written in NFD would fail here. # # The roster is _VOCAB_FIELDS, which is the roster __post_init__ - # and __setstate__ normalize -- so a thirteenth vocabulary field + # and __setstate__ normalize -- so a fourteenth vocabulary field # has to join it for add/remove to work at all, and joining it # gets the field checked here for free (#322/#323 review). lex = Lexicon.default() From c43c5c25cc95714ce335d14438d45c2521ce8d35 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Sun, 13 Sep 2026 16:18:01 -0700 Subject: [PATCH 2/6] fix(#383/#479): a single-letter connective joins only on case evidence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A name written wholly in one case -- all upper or all lower alike -- carries no case evidence about any letter in it, so a single-letter connective's reading now comes from the vocabulary instead of from its written shape: a member of the new `conjunctions_ambiguous` subset ("e" by default) reads as an initial and reports CONJUNCTION_OR_INITIAL, a non-member joins even as a bare capital. Mixed-case input keeps rules.md#P3's bare-Latin-capital rule verbatim -- that half was never in question, only the one-case half was evidence-free -- and a caseless letter (Arabic و) never enters the fork at all. The whole behavior change is a classify-time fork in `_tags_for`, gated on the name's OWN words exactly as P3's three-word count already was: a word inside a delimited clause (a maiden marker's tail, a nickname) is read by whatever rule already reads it, not by this one. `is_one_case` mirrors R5's own case-repair comparison by design, and the emitter reports only where the vocabulary itself marks a letter ambiguous, so an orphaned `conjunctions_ambiguous` entry stays inert rather than firing on its own. `jose e maria santos` reads middle "e maria" where it joined; `JUAN GARCIA Y LOPEZ` reads family "GARCIA Y LOPEZ" where the Latin-capital veto used to give middle "GARCIA Y"; `john e smith` initials "j. e. s." and capitalizes "John E Smith". Seventeen corpus names carry a cased single-letter connective in a one-case spelling -- the whole population this change can reach -- and ten of them move a role, a report, or the core's derived initials(). The seventeen case rows and their corpora (`corpus_shapes.jsonl`, `corpus_rules.jsonl`) pin the population; the ledgers classify each mover: the role rule stands at all five baselines, the report rule only where `_ambiguities` exists to carry it (2.0.0 onward), `JUAN Y GARCIA`'s bare `_initials` move gets its own rule at 2.3.0 alone (2.0.0-2.2.0 already explain it under `fix(#462)`), and the Arabic `محمد و علي` gets a `feat(#269)` rule at 1.4.0 for a pre-existing, unrelated fact this change's case-table example merely brought into the corpus. 1.4.0 carries no rule for this PR at all: the v1 facade re-derives the connective decision from vocabulary and initial shape on raw text instead of reading the parse's tags, so `HumanName.initials()` does not move, and the facade is the only surface compared below 2.0. That facade/ core split is pinned by a contrastive test and recorded at decisions.md#P3, with a follow-up issue to file separately. `tests/v2/test_ledger_guards.py`'s `_WATCHED_DIFFS` retires five rows made redundant by three corpus names crossing into cases.py/render literals ("Jose e Maria Santos" and "Juan Garcia y Lopez" at 1.4.0, "JOSE E MARIA SANTOS" at 2.0.0-2.2.0) -- the Aishwarya Rai precedent: each name now carries its own case row asserting the whole parse, so nothing goes unwatched. `tests/test_capitalization.py`'s `test_a_one_letter_conjunction_is_case_sensitive_to_repair` moves: `JUAN Y GARCIA` was pinned as inherited 1.4.0 behavior ("Juan Y Garcia"), and the fork now reads its bare capital "Y" as the connective for want of case evidence, agreeing with the lowercase spelling ("Juan y Garcia"); a mixed-case control beside it pins that "Juan Y Garcia" is untouched. Closes #383, closes #479 Co-Authored-By: Claude Fable 5.1 --- docs/design/rules.md | 87 +++++-- nameparser/_lexicon.py | 7 +- nameparser/_pipeline/_classify.py | 132 +++++++++-- nameparser/_pipeline/_group.py | 4 +- nameparser/_pipeline/_vocab.py | 33 +++ tests/test_capitalization.py | 105 +++++--- tests/test_initials.py | 5 +- tests/v2/cases.py | 209 ++++++++++++++++ tests/v2/pipeline/test_classify.py | 186 ++++++++++++++- tests/v2/pipeline/test_vocab.py | 25 +- tests/v2/test_contracts.py | 6 +- tests/v2/test_ledger_guards.py | 237 ++++++++++++++++++- tests/v2/test_render.py | 35 +++ tools/differential/compare.py | 59 +++-- tools/differential/corpus_rules.jsonl | 5 + tools/differential/corpus_shapes.jsonl | 17 ++ tools/differential/expected_since_1.4.0.toml | 112 ++++++++- tools/differential/expected_since_2.0.0.toml | 117 ++++++++- tools/differential/expected_since_2.1.0.toml | 103 +++++++- tools/differential/expected_since_2.2.0.toml | 103 +++++++- tools/differential/expected_since_2.3.0.toml | 129 ++++++++++ 21 files changed, 1595 insertions(+), 121 deletions(-) diff --git a/docs/design/rules.md b/docs/design/rules.md index b21b21b7..6d4b2160 100644 --- a/docs/design/rules.md +++ b/docs/design/rules.md @@ -474,20 +474,42 @@ P2. Rationale: a particle is written as part of the surname it P3. Rationale: connective words ("y", "of the") bind name words into one name part; but a single letter in a short name is more - likely an initial than a connective. + likely an initial than a connective, and where a name is written + in more than one case, a bare Latin capital standing alone is how + an initial is marked and a bare lowercase letter is how it is + not. A recognized connective joins its neighbors into one name part, connective runs included — except a single-letter connective in a three-word name, which stays a name word, and a single-letter - connective written as a bare Latin capital, which reads as an - initial and never joins. The joined part is ONE name word - wherever another rule counts them, so a rule taking "one name - word" takes the whole join and never half of it. The three-word - count is of the name's own words: a maiden marker taken as one, - and the words it takes (M2), are not among them, so a maiden - clause does not change whether the connective joins. A marker - left as a word (M2) is a word, and counts. + connective that reads as an initial instead, which never joins. + A single-letter connective reads as an initial where the writing + says so: written as a bare Latin capital in a name that is not written + wholly in one case, or — in a name written wholly in one case, + where nothing says so — where the letter is one the vocabulary + marks as reading both ways. + A letter the vocabulary marks as reading both ways, read as an + initial in a name written wholly in one case, is a call that could + have gone the other way, and is reported. + The joined part is ONE name word wherever another rule counts + them, so a rule taking "one name word" takes the whole join and + never half of it. + Both questions this rule asks of a name — how many words it has, + and whether it is written in one case — are asked of the name's + OWN words: a maiden marker taken as one, and the words it takes + (M2), are not among them, and neither is a delimited clause (N1, + M1). So a clause beside the name changes neither whether the + connective joins nor how a letter in the name reads, and a letter + inside such a clause is the clause's word, read as it always was + and not by this rule. The two questions part at one point: a + marker the pass declines and leaves as a word (M2) is a word, and + counts toward the three — but its case is still not asked. "Juan y Eva Garcia" → given="Juan y Eva" "Jose E Maria Santos" → middle="E Maria" + "jose e maria santos" → middle="e maria" + "Jose e Maria Santos" → given="Jose e Maria" + "JUAN GARCIA Y LOPEZ" → family="GARCIA Y LOPEZ" + "juan garcia y lopez" → family="garcia y lopez" + "john e smith" → middle="e" "Juan y Garcia" → middle="y" · boundary "Juan y Garcia née Jones" → middle="y" "Juan and Garcia" → given="Juan and Garcia" @@ -498,19 +520,52 @@ P3. Rationale: connective words ("y", "of the") bind name words into three-word carve-out counts letters, so a symbol connective joins at any length, and it reaches every single-letter connective the vocabulary holds — Cyrillic и/і/й and Arabic و as well as y and - e. Which single letters a tradition actually wants joined differs - by language, and no locale gets its own answer today. - Accepted: the initial veto is a LATIN shape — a Cyrillic - capital joins ("И".isupper() is true, so this is not a - Unicode-uppercase rule); #267's closure blessed the Cyrillic - side, and whether the Latin-capital half should stand is #383. + e. The initial reading counts letters too, and asks one more + question of them: a letter with no case at all (و) can be written + against nothing, so it never reads as an initial. A Cyrillic + capital has case but is not the shape an initial is written in — + Cyrillic abbreviates with a dotted letter — so in a name of more + than one case it joins, the reading #267 blessed, and in a name + of one case it takes the vocabulary's answer like any other cased + letter. + Which single letters a tradition actually wants joined differs by + language, and the marked set is where that answer lives: "y" is + the commonest Hispanic compound and stays out of it, "e" is a + common bare initial and is the one entry shipped. A caller with + Portuguese data removes it; a caller with Dutch data adds "y". + The initial reading is visible beyond the fields, on the two + derived views: parse("john e smith").initials() gives "j. e. s." + and .capitalized() gives "John E Smith", where the connective + reading gave "j. s." and "John e Smith" — a connective + contributing no initial (R3) and keeping its lowercase (R4), + where an initial does neither. The v1 facade's initials() still + reads the letter by vocabulary and written shape rather than by + the parse's reading, so HumanName("john e smith").initials() + stays "j. s." for now; decisions.md#P3 records the split. + Accepted: two 1.4.0 parity breaks, one in each direction. A bare + capital in a name written wholly in upper case no longer reads as + an initial, so "JUAN GARCIA Y LOPEZ" joins where 1.4.0 and + 2.0–2.3 read middle "GARCIA Y"; and a marked lowercase letter in + a name written wholly in lower case no longer joins, so "jose e + maria santos" reads middle "e maria" where they read given "jose + e maria". That is #383 answered with "bless" for the mixed-case + half — a bare Latin capital among mixed case still reads as an + initial, the shape the veto always tested, which is why #267's + Cyrillic reading is untouched — and answered with evidence for + the one-case half, where the vocabulary decides. + A maiden marker the marker pass declines (left as a word, M2) is + nonetheless excluded from the case class, which shows only when + the marker word is the only differently-cased token — "JUAN Y + GARCIA née" joins where "JUAN Y GARCIA née Jones" keeps "Y" a + name word — accepted rather than repaired, since classify cannot + know what group will decline. "Хосе И Мария Сантос" → given="Хосе И Мария" H1 is the counting rule that shows the one-word clause today: a title plus the join reads the whole join as the family, where the same two words unjoined are two name words and H1 does not fire. P1's leading run becomes the second once #395 lands — its run must take the "Vega y Santos" join whole or stop before it. - history: decisions.md#P3 · interacts: H1, P1, M2 · implemented: nameparser/_pipeline/_group.py, nameparser/_pipeline/_post_rules.py + history: decisions.md#P3 · interacts: H1, P1, M2, R3, R4 · implemented: nameparser/_pipeline/_classify.py, nameparser/_pipeline/_group.py, nameparser/_pipeline/_post_rules.py P4. Rationale: a particle links forward from inside a name; at the very front there is no name yet to be inside. diff --git a/nameparser/_lexicon.py b/nameparser/_lexicon.py index 57b99193..74241cf1 100644 --- a/nameparser/_lexicon.py +++ b/nameparser/_lexicon.py @@ -66,9 +66,10 @@ #: Two fields are deliberately NOT here and have no check of their own. #: given_name_titles -- see the note in __post_init__ for why every #: attempt at one rejected working configurations. conjunctions_ambiguous -#: -- an orphan is never consulted because the classify fork tests -#: conjunctions first, so it is inert rather than harmful, and -#: AGENTS.md's invariants rule guards harm, not no-ops. +#: -- an orphan is never consulted because both the classify fork and +#: its ambiguity emitter require the base entry as well, so an orphan +#: decides nothing, and AGENTS.md's invariants rule guards harm, not +#: no-ops. _SUBSET_FIELDS = ( ("particles_ambiguous", "particles", "an orphan emits a spurious particle-or-given ambiguity"), diff --git a/nameparser/_pipeline/_classify.py b/nameparser/_pipeline/_classify.py index b1eb885b..9a2e6772 100644 --- a/nameparser/_pipeline/_classify.py +++ b/nameparser/_pipeline/_classify.py @@ -3,7 +3,8 @@ Consumes: tokens, comma_offsets (with token roles, the two halves of the structural-boundary test the marker pass applies -- see _tag_marker_runs). -Produces: tokens with vocabulary tags added (text/span/role unchanged). +Produces: tokens with vocabulary tags added (text/span/role unchanged), +plus ambiguities (SUFFIX_OR_NICKNAME, CONJUNCTION_OR_INITIAL). Reads: every Lexicon vocabulary field except surnames and honorific_tails, which script_segment consumes upstream; no Policy FIELD is consulted (is_initial does consult the _policy module's @@ -40,8 +41,8 @@ ) from nameparser._types import AmbiguityKind, Role from nameparser._pipeline._vocab import ( - _longest_marker, is_initial, maiden_marker_head, maiden_marker_run, - period_joined_vocab, suffix_as_written, + _longest_marker, is_initial, is_one_case, maiden_marker_head, + maiden_marker_run, period_joined_vocab, suffix_as_written, ) @@ -55,12 +56,19 @@ # bare ambiguous acronym is consumed only when the name has words to # spare" def _tags_for(token: WorkToken, n: str, state: ParseState, - marker_tag: str | None) -> frozenset[str]: + marker_tag: str | None, one_case_own: bool) -> frozenset[str]: """`n` is _normalize(token.text), folded once by the caller and shared with the marker pass; `marker_tag` is what that pass decided for this token, or None. The marker DECISION is entirely _tag_marker_runs'; only the writing happens here, so the two tokens - of a phrase are built once rather than replaced twice.""" + of a phrase are built once rather than replaced twice. + + `one_case_own` is true when the whole name is written in one case + AND this token is one of the name's own words -- a maiden clause + and any delimited (nickname) content are not, so the fork never + reads them either (rules.md#P3): a clause's words are not the + name's own words, and appending one must not change how THIS token + reads.""" lex = state.lexicon tags = set(token.tags) if marker_tag is not None: @@ -79,10 +87,36 @@ def _tags_for(token: WorkToken, n: str, state: ParseState, tags.add("particle") if n in lex.particles_ambiguous: tags.add("vocab:particle-ambiguous") - if n in lex.conjunctions and not is_initial(token.text): - # v1's is_conjunction excludes initials: 'e.' in 'john e. smith' - # is a middle initial, not the Spanish conjunction 'e' - tags.add("conjunction") + # rules.md#P3: "a single-letter connective reads as an initial + # where the writing says so: written as a bare Latin capital in a + # name that is not written wholly in one case, or — in a name + # written wholly in one case, where nothing says so — where the + # letter is one the vocabulary marks as reading both ways" + # (#383/#479; history: decisions.md#P3) + cased_single = (len(token.text) == 1 + and token.text.upper() != token.text.lower() + and n in lex.conjunctions) + if cased_single and one_case_own: + # No case evidence, so the vocabulary decides. No namespaced + # tag beside it: the emitted ambiguity IS the record of the + # decision (mechanisms.md#MARK-DONT-STRIP is satisfied by the + # report) -- mechanisms.md#AMBIGUITY-AT-THE-DECISION-SITE says + # emit where the branch is taken, not where an ambiguous tag + # sits, and a vocab: tag records MEMBERSHIP, not the branch + # taken, so it is the wrong shape of record here. + if n in lex.conjunctions_ambiguous: + tags.add("initial") + else: + # a bare capital y joins here, where mixed case vetoes it + tags.add("conjunction") + else: + # today's rule, verbatim. v1's is_conjunction excludes + # initials: 'e.' in 'john e. smith' is a middle initial, not + # the Spanish conjunction 'e' + if n in lex.conjunctions and not is_initial(token.text): + tags.add("conjunction") + if is_initial(token.text): + tags.add("initial") if n in lex.bound_given_names: tags.add("vocab:bound-given") # maiden markers are NOT tagged here: an entry may be a phrase whose @@ -90,8 +124,6 @@ def _tags_for(token: WorkToken, n: str, state: ParseState, # token with no neighbours. _tag_marker_runs below does the whole # field, single words included, so there is one place that decides # it (mechanisms.md#ONE-PREDICATE-PER-QUESTION). - if is_initial(token.text): - tags.add("initial") # v1's period-joined derivation (parse_pieces): a token with a # period not at the end, ANY of whose period chunks is a title, is # a title as a whole ('Lt.Gov.', and by the ANY rule 'Mr.Smith'); @@ -204,12 +236,50 @@ def _tag_marker_runs(state: ParseState, def classify(state: ParseState) -> ParseState: # One fold per token, shared by the marker pass and the vocabulary # tags -- the shape suffix_as_written already asks for ("n is - # _normalize(text), passed in so callers normalize once"). - folded = [_normalize(t.text) for t in state.tokens] + # _normalize(text), passed in so callers normalize once"). `texts` + # is built once and reused for `folded`: a generator handed to + # is_one_case instead costs one profiler frame per RESUME, i.e. per + # token, on every parse (#475's frame-count band caught this). + texts = [t.text for t in state.tokens] + folded = [_normalize(x) for x in texts] marker_tags = _tag_marker_runs(state, folded) + # rules.md#P3 says a maiden marker, taken as one, and the words it + # takes, are not among the name's own words -- so clause_at is the + # smallest index tagged as a marker HEAD, and everything from there + # on is the clause. A plain loop, not a generator handed to min(): + # marker_tags is almost always empty, and its keys arrive in index + # order (_tag_marker_runs walks left to right), so the first head a + # forward walk finds is already the smallest. Filtering on the HEAD + # tag specifically (never "-cont") is what makes that answer right + # independent of _tag_marker_runs's insertion order too: every + # matching entry is a clause start, so a min() over them in any + # order would agree with this walk -- the walk just takes the + # cheaper path given the order this dict happens to arrive in. + clause_at = len(texts) + for i, tag in marker_tags.items(): + if tag == "vocab:maiden-marker": + clause_at = i + break + # ONE fact per parse, taken over the name's OWN words (rules.md#P3): + # not a delimited clause's tokens, which arrive with `role` already + # set by extract (WorkToken.role's docstring), and not the maiden + # clause itself, which starts at clause_at. Appending a clause must + # not flip the reading of words that did not change. Not stored on + # ParseState: nothing downstream reads it today, and #289/#516 can + # promote it the way `order` was recorded rather than recomputed. + own = [texts[i] for i, t in enumerate(state.tokens) + if i < clause_at and t.role is None] + one_case = is_one_case(own) + # The fork itself must not read a clause's words either: `own_word` + # is the same "own words" test as `own` above, applied per token so + # the fork and its emitter agree with the case class they consult. + # No extra frame -- it is one more boolean in a comprehension + # that already walks every token. tokens = tuple( dataclasses.replace( - t, tags=_tags_for(t, folded[i], state, marker_tags.get(i))) + t, tags=_tags_for(t, folded[i], state, marker_tags.get(i), + one_case and i < clause_at + and t.role is None)) for i, t in enumerate(state.tokens)) # Delimited content whose vocabulary cannot settle it: extract's # escape sends an UNambiguous suffix straight through ("(MBA)" -> @@ -226,5 +296,39 @@ def classify(state: ParseState) -> ParseState: f"delimited {token.text!r} is also a post-nominal; read " f"as a nickname rather than a suffix", (i,))) + # #383/#479: narrows the fork's own "initial" tag with the same + # inputs the fork used, rather than re-deciding from scratch. + # Only the casedness test is inherited from the tag -- + # `is_initial(token.text)` also tags a bare capital "initial" + # in the fork's else branch, so the tag alone does not tell + # this apart from that. + # + # Of the two clauses beside it, one is load-bearing and one is + # not. `conjunctions_ambiguous` is IMPLIED by the others for a + # fresh parse: "initial" plus len 1 forces an ASCII capital, + # hence cased, hence the fork's branch was taken. It is kept + # only because `_tags_for` starts from `set(token.tags)`, so + # this clause is what keeps the emitter honest if a runner ever + # hands classify tokens it did not build; no such path exists + # today. `conjunctions` is the clause doing real work: it keeps + # an orphan marker (a conjunctions_ambiguous entry no longer in + # conjunctions) inert rather than reported (decisions.md#P3). + # `i < clause_at and token.role is None` mirrors the fork's own + # "own words" test above, for the same reason (rules.md#P3): a + # clause's words were never eligible for the fork, so they must + # never be eligible to report either. Emitted at the decision + # site (mechanisms.md#AMBIGUITY-AT-THE-DECISION-SITE), per + # token -- 'e and e' reports twice. + if (one_case and "initial" in token.tags + and len(token.text) == 1 + and folded[i] in state.lexicon.conjunctions_ambiguous + and folded[i] in state.lexicon.conjunctions + and i < clause_at and token.role is None): + ambiguities.append(PendingAmbiguity( + AmbiguityKind.CONJUNCTION_OR_INITIAL, + f"{token.text!r} is both a connective and an initial; " + f"the name is written in one case, so nothing marks " + f"which, and it is read as an initial", + (i,))) return dataclasses.replace(state, tokens=tokens, ambiguities=tuple(ambiguities)) diff --git a/nameparser/_pipeline/_group.py b/nameparser/_pipeline/_group.py index c9817fee..f1a62b0d 100644 --- a/nameparser/_pipeline/_group.py +++ b/nameparser/_pipeline/_group.py @@ -275,8 +275,8 @@ def _maiden_take(pieces: Sequence[Sequence[int]], # rules.md#P3: "a recognized connective joins its neighbors into one # name part, connective runs included — except a single-letter # connective in a three-word name, which stays a name word, and a -# single-letter connective written as a bare Latin capital, which -# reads as an initial and never joins" (history: decisions.md#P3) +# single-letter connective that reads as an initial instead, which +# never joins" (history: decisions.md#P3) def _is_conj_piece(piece: Sequence[int], ptags: Set[str], tokens: Sequence[WorkToken]) -> bool: if "conjunction" in ptags: diff --git a/nameparser/_pipeline/_vocab.py b/nameparser/_pipeline/_vocab.py index 2cb4cd1d..56bd50b6 100644 --- a/nameparser/_pipeline/_vocab.py +++ b/nameparser/_pipeline/_vocab.py @@ -144,6 +144,39 @@ def is_initial(text: str) -> bool: return is_initial_shaped(text) and not in_initialless_script(text) +def is_one_case(texts: Sequence[str]) -> bool: + """Whether a name is written wholly in ONE case -- all upper or all + lower alike -- and so carries no case EVIDENCE about any letter in + it (rules.md#P3, #383/#479). The caller passes the name's OWN + words: a maiden marker's clause and any delimited (nickname) + content are not among them, and appending one must not flip the + reading of words that did not change. + + Mirrors the SHAPE of the comparison the R5 gate in + `_render.capitalized` makes, not its SPAN: R5 joins every token, + nickname and maiden content included, while classify's caller hands + in only the name's own words (rules.md#P3's own-words doctrine, see + above) -- so a clause-bearing name can be one-case to this function + and mixed to R5 (measured: `'JUAN GARCIA Y LOPEZ née Jones'` is one + case here, mixed there). Not shared by import today -- render is a + layer this module does not reach into, and #492 is where the two + spans are reconciled if they ever need to be. + + `Sequence`, not `Iterable`: the caller passes a list it already + built rather than a fresh generator, so `is_one_case` costs one + profiler frame per parse rather than one per token (#475). + + A CASELESS script answers True, harmlessly: `'محمد و علي'.upper()` + is the string itself, so the comparison holds, and the only caller + also requires a token whose own `upper()` and `lower()` differ -- + which a caseless letter's never do. So a caseless name never + reaches the decision this gates, and "one case" is the honest + verdict for text that has only one. + """ + joined = " ".join(texts) + return joined in (joined.upper(), joined.lower()) + + _DOTTED = re.compile(r"(?:[^\W\d_]\.)+") diff --git a/tests/test_capitalization.py b/tests/test_capitalization.py index 4c8f0576..264bcfb7 100644 --- a/tests/test_capitalization.py +++ b/tests/test_capitalization.py @@ -218,16 +218,16 @@ def test_capitalize_all_particle_family_beside_a_conjunction(self) -> None: # ignore the input's case entirely -- one name, one repaired # string, however it was written. # - # Measured over the 1094-name differential corpus (2026-08-29), - # because the promise is nearly true and the exceptions are the - # whole story. On THIS surface -- `HumanName.capitalize()` and - # `str()`, which is what the test below uses -- forcing repair - # differs from uppercasing the input and calling `capitalize()` - # for 62 of the 1094, and from lowercasing it for 16, so - # UPPERCASE IS THE WORSE DIRECTION, not the clean one. Nor are - # the misses merely parse-level: of the 62, only 25 move a role, - # and the other 37 parse byte-identically and differ inside the - # repair itself. Through the v2 core -- + # Measured 2026-08-29, before the #383/#479 fork, on the + # 1094-name corpus, because the promise is nearly true and the + # exceptions are the whole story. On THIS surface -- + # `HumanName.capitalize()` and `str()`, which is what the test + # below uses -- forcing repair differs from uppercasing the input + # and calling `capitalize()` for 62 of the 1094, and from + # lowercasing it for 16, so UPPERCASE IS THE WORSE DIRECTION, not + # the clean one. Nor are the misses merely parse-level: of the 62, + # only 25 move a role, and the other 37 parse byte-identically and + # differ inside the repair itself. Through the v2 core -- # `parse(n).capitalized(force=True)`, rendering all seven roles # -- the counts are 63 and 38, which is what decisions.md#R5 # states. The one name the facade cannot see is @@ -235,19 +235,45 @@ def test_capitalize_all_particle_family_beside_a_conjunction(self) -> None: # MAIDEN name: `str(HumanName)` renders the default spec, and # that spec omits the field. Recompute by running both forms over # the corpus files deduped and diffing, on whichever surface - # you name. + # you name. The #383/#479 fork removes the one-letter-conjunction + # mechanism behind the uppercase direction (below), and a + # re-measurement on this tree REVERSES the headline: the uppercase + # count collapses and the direction flips, uppercase now differing + # on fewer names than lowercase rather than more. The dated + # re-measurement and its recipe (with the actual counts) live + # under decisions.md#R5, written in commit C. # - # The mechanism is v1's initial carve-out, taken in the PARSE - # since #458 and read off the tag by the repair: a word of the - # conjunction vocabulary is not tagged one where it is written - # initial-shaped, and initial-shaped means one CAPITAL letter - # (nameparser/_pipeline/_classify.py, and the tests beside it -- - # the repair no longer asks). Uppercase a name and - # every one-letter conjunction becomes an initial; lowercase one - # and a middle initial `E` becomes the Italian conjunction. So - # the property is pinned over names carrying no single-letter - # word whose class case decides, and the exception is pinned - # beside it as data rather than left to be rediscovered. + # THROUGH 2.3.0 the mechanism was v1's initial carve-out, taken in + # the PARSE since #458 and read off the tag by the repair: a word + # of the conjunction vocabulary was not tagged one where it was + # written initial-shaped, and initial-shaped meant one CAPITAL + # letter, full stop -- uppercase a name and every one-letter + # conjunction became an initial; lowercase one and a middle + # initial `E` became the Italian conjunction. `Velasquez y Garcia, + # Dr. Juan Q.` forced kept `y`; uppercased then repaired gave `Y` + # (decisions.md#R5). + # + # #383/#479 (rules.md#P3, decisions.md#P3) removed "one CAPITAL + # letter" as the whole test: a name written wholly in one case + # carries no case evidence, so the vocabulary decides instead of + # the shape. Only the letters `Lexicon.conjunctions_ambiguous` + # marks (just 'e' today) still read as an initial in a one-case + # name, upper or lower alike -- so a middle initial `E` lowercased + # into a one-case name stays an initial rather than becoming the + # connective. A plain conjunction like 'y' now joins in a one-case + # name the same way whichever case it is written in. This worked + # example + # has no bare `E`, so only the uppercase half is gone for IT -- + # measured, `HumanName('VELASQUEZ Y GARCIA, DR. JUAN Q.').capitalize()` + # now agrees with the forced and lowercased forms, all three + # giving `Dr. Juan Q. Velasquez y Garcia`; the lowercase half is + # shown instead by `john e smith` + # (tests/v2/test_render.py::test_capitalized_one_case_connective_that_reads_as_an_initial). + # decisions.md#R5 carries the dated amendment for this. So the + # property is pinned over names + # carrying no single-letter word whose class case decides, and the + # `conjunctions_ambiguous` exception is pinned beside it as data + # rather than left to be rediscovered. def test_forcing_repair_ignores_the_case_it_was_given(self) -> None: for name in ('shirley maclaine', 'juan de la vega', 'anh van do', 'donovan mcnabb-smith', 'jane smith phd', @@ -261,19 +287,40 @@ def test_forcing_repair_ignores_the_case_it_was_given(self) -> None: self.m(str(upper), str(forced), upper) self.m(str(lower), str(forced), lower) - # The recorded exception to the property above, and the reason it - # is scoped rather than universal. 1.4.0 does exactly this too - # (measured on the released wheel: 'JUAN Y GARCIA' capitalizes to - # 'Juan Y Garcia'), so it is inherited behavior and not a 2.x - # regression -- recorded here, deliberately not fixed here. + # This WAS the recorded exception to the property above through + # 2.3, and the reason it was scoped rather than universal. The + # #383/#479 fork (rules.md#P3, decisions.md#P3) reads a bare + # capital single-letter conjunction in a one-case name as the + # connective -- no case evidence says otherwise -- so + # 'JUAN Y GARCIA' and 'juan y garcia' now repair to the same + # string and the property above holds for them too. 1.4.0's + # 'Juan Y Garcia' (measured on the released wheel) is the parity + # break this PR's ledgers classify. + # + # The name is kept for the blame trail even though it now + # overstates: what decides repair is the NAME's case class, not + # the conjunction letter's own case -- 'y' reads the same way + # whichever case it is itself written in, as long as the name + # around it is one-case. def test_a_one_letter_conjunction_is_case_sensitive_to_repair(self) -> None: lowered = HumanName('juan y garcia') lowered.capitalize(force=True) self.m(str(lowered), 'Juan y Garcia', lowered) uppered = HumanName('JUAN Y GARCIA') uppered.capitalize() - # 'Y' is initial-shaped, so the conjunction rule declines it - self.m(str(uppered), 'Juan Y Garcia', uppered) + # #383/#479 fork, no longer inherited 1.4.0 behavior: 'JUAN Y + # GARCIA' is written wholly in one case, so its bare capital + # 'Y' carries no case evidence -- 'y' is not in + # conjunctions_ambiguous, so it reads as the connective rather + # than as an initial, and R4's carve-out lowercases it exactly + # as it lowercases the all-lower spelling above (decisions.md#P3) + self.m(str(uppered), 'Juan y Garcia', uppered) + # mixed-case control: here the capital IS evidence, so 'Y' + # reads as an initial exactly as it always has and repair + # declines to touch it + mixed = HumanName('Juan Y Garcia') + mixed.capitalize() + self.m(str(mixed), 'Juan Y Garcia', mixed) # The v1 parity this rests on, at the surface v1 users have. An # ASSIGNED field is spliced in as raw text and never classified, so diff --git a/tests/test_initials.py b/tests/test_initials.py index dcb80b2b..89d10969 100644 --- a/tests/test_initials.py +++ b/tests/test_initials.py @@ -262,7 +262,10 @@ def test_initials_drop_a_bare_non_ascii_conjunction_letter(self) -> None: def test_initials_still_drop_a_lowercase_conjunction(self) -> None: # the boundary #462 leaves alone: a bare lowercase e/y IS the - # connective, and 1.4.0 and 2.x agree + # connective, and 1.4.0 and 2.x agree -- true of this facade + # surface only since #383/#479: the core's parse().initials() + # now reads a one-case 'e' as an initial instead + # (tests/v2/test_render.py::test_facade_initials_do_not_yet_follow_the_one_case_fork) hn = HumanName("john e smith") self.m(hn.initials(), "j. s.", hn) hn = HumanName("maria y lopez") diff --git a/tests/v2/cases.py b/tests/v2/cases.py index 1729a04a..53e1c956 100644 --- a/tests/v2/cases.py +++ b/tests/v2/cases.py @@ -1000,6 +1000,215 @@ def _check_cjk_shape_purity(self) -> None: Case("initial_shaped_not_conjunction", "john e. smith", {"given": "john", "middle": "e.", "family": "smith"}, notes="v1 is_conjunction excludes initials at classify too"), + # #383/#479: a single-letter connective joins only on positive + # evidence, and a name written wholly in one case has none. These + # 17 rows pin the FORK, not the wordlist + # (mechanisms.md#VOCABULARY-EXERCISES-FORKS). + # + # The 12 Latin e/y rows, plainly: 'e' at four words carries + # one-case-lower ('jose e maria santos'), one-case-upper ('JOSE E + # MARIA SANTOS'), mixed-lower where 'e' joins ('Jose e Maria + # Santos'), and mixed-upper where 'E' is an initial ('Jose E Maria + # Santos') -- four spellings, because within a mixed-case name the + # letter's OWN case still decides which reading it gets. 'e' at + # three words carries the same one-case-lower/one-case-upper/mixed- + # lower trio ('john e smith' / 'JOHN E SMITH' / 'John e Smith'); + # its mixed-upper twin ('John E Smith') is absent because it would + # pin nothing these rows do not -- the three-word carve-out already + # keeps 'e' a name word either way, exactly as row 5 does. 'y' + # carries one-case-upper and one-case-lower at four words ('JUAN + # GARCIA Y LOPEZ' / 'juan garcia y lopez'), mixed-upper at four + # words where 'Y' still vetoes ('Juan Garcia Y Lopez'), and one- + # case-upper/one-case-lower at three words where the carve-out + # already held ('JUAN Y GARCIA' / 'juan y garcia'). 'Juan Garcia y + # Lopez' (mixed-lower, four words) is absent for the same reason as + # 'John E Smith': it pins the same BRANCH the mixed-lower 'e' row + # already demonstrates, a different JOIN though -- 'Garcia y Lopez' + # into the family where 'Jose e Maria' joins into the given run. + # 'Juan Y Garcia' (mixed-UPPER, three words) is absent because its + # control lives beside the capitalize() pin instead, in + # tests/test_capitalization.py::test_a_one_letter_conjunction_is_case_sensitive_to_repair; + # 'Juan y Garcia' (mixed-lower, three words) is absent everywhere as + # an input. + # + # The other five rows are three DIFFERENT reasons a row does not + # move, not "three scripts that must not enter the fork": the + # Cyrillic pair DOES enter the fork -- 'и' is cased conjunction + # vocabulary and takes the non-member branch exactly as 'y' does, + # joining without a report -- the Catalan pair's 'i' is Latin script + # but is not conjunction vocabulary AT ALL, so it never reaches + # `cased_single` in the first place (a different, earlier exclusion + # than Cyrillic's, and #397's before-picture); only the Arabic row + # is genuinely caseless and so never enters the fork on that + # ground. + # + # All 17 rows are shape-tagged so build_shapes_corpus.py projects + # them into the contract corpus -- measured 2026-09-13, no corpus + # file held a uniform-case name with a single-letter connective + # beyond the eight already there, and none held a standalone و at + # all, so without these the fork is invisible to every future gate + # run. + Case("one_case_lower_e_reads_as_an_initial", "jose e maria santos", + {"given": "jose", "middle": "e maria", "family": "santos"}, + classification="fix(#479)", + ambiguities=("conjunction-or-initial",), + notes="#479 row 1, the defect. 1.4.0 and 2.0-2.3 alike read " + "'e' as the connective and gave given 'jose e maria'. " + "A lowercase letter in an all-lowercase name is no more " + "evidence than an uppercase one in an all-uppercase " + "name, so the vocabulary decides and 'e' is marked", + shape=1), + Case("one_case_upper_e_reads_as_an_initial", "JOSE E MARIA SANTOS", + {"given": "JOSE", "middle": "E MARIA", "family": "SANTOS"}, + ambiguities=("conjunction-or-initial",), + notes="#479 row 4. The READING is unchanged from 1.4.0 and " + "from 2.3 -- what is new is the report: an all-upper " + "name's capital is not the evidence a mixed-case name's " + "capital is, so the same fork was being called silently", + shape=1), + Case("mixed_case_lower_e_is_the_connective", "Jose e Maria Santos", + {"given": "Jose e Maria", "family": "Santos"}, + notes="the mixed-case control for the row above: here the " + "lowercase letter IS evidence, because the rest of the " + "name is not lowercase, so 'e' joins and nothing is in " + "doubt", + shape=1), + Case("mixed_case_upper_e_is_an_initial", "Jose E Maria Santos", + {"given": "Jose", "middle": "E Maria", "family": "Santos"}, + notes="the other mixed-case control: a bare capital among " + "mixed case is how an initial is written, unchanged " + "since 1.4.0 and unreported", + shape=1), + Case("one_case_three_word_e_is_an_initial", "john e smith", + {"given": "john", "middle": "e", "family": "smith"}, + ambiguities=("conjunction-or-initial",), + notes="P3's three-word carve-out is untouched -- 'e' stays a " + "name word either way -- so the ROLES do not move and " + "the visible change is the tag: ParsedName.initials() " + "(the v2 view this table exercises) gives 'j. e. s.' " + "where 2.3 gave 'j. s.', and capitalize() gives 'John E " + "Smith' where 2.3 gave 'John e Smith', pinned in " + "tests/v2/test_render.py. Measured 2026-09-13: the v1 " + "facade's HumanName.initials() does not yet follow this " + "fork and still gives 'j. s.' -- " + "test_facade_initials_do_not_yet_follow_the_one_case_fork " + "in tests/v2/test_render.py pins the split; closing it " + "is a follow-up issue's job. This table asserts roles", + shape=1), + Case("one_case_upper_three_word_e_is_an_initial", "JOHN E SMITH", + {"given": "JOHN", "middle": "E", "family": "SMITH"}, + ambiguities=("conjunction-or-initial",), + notes="the all-upper spelling of the row above: same reading, " + "and the report is what is new", + shape=1), + Case("mixed_case_three_word_e_is_the_connective", "John e Smith", + {"given": "John", "middle": "e", "family": "Smith"}, + notes="the mixed-case control at three words. The carve-out " + "means the ROLE is the same as the row above; the " + "difference is the tag, and so the initials -- 'J. S.' " + "here against 'J. E. S.' for 'JOHN E SMITH'", + shape=1), + Case("one_case_upper_y_joins_as_a_bare_capital", "JUAN GARCIA Y LOPEZ", + {"given": "JUAN", "family": "GARCIA Y LOPEZ"}, + classification="fix(#383)", + notes="#383 answered with 'bless', and this is the half that " + "moves: 1.4.0 through 2.3 vetoed a bare Latin capital " + "into an initial and gave middle 'GARCIA Y'. An " + "all-upper name's capital is not evidence, 'y' is not " + "marked as reading both ways, so it joins -- and " + "reports nothing, because nothing about it is in doubt", + shape=1), + Case("one_case_lower_y_joins", "juan garcia y lopez", + {"given": "juan", "family": "garcia y lopez"}, + notes="the parity half of the pair above: a lowercase 'y' " + "joined before this change and joins after it", + shape=1), + Case("mixed_case_upper_y_is_an_initial", "Juan Garcia Y Lopez", + {"given": "Juan", "middle": "Garcia Y", "family": "Lopez"}, + notes="the mixed-case control: here the capital IS evidence, " + "so the bare 'Y' reads as an initial exactly as it " + "always has, and the join does not happen", + shape=1), + Case("one_case_upper_y_keeps_the_three_word_carveout", "JUAN Y GARCIA", + {"given": "JUAN", "middle": "Y", "family": "GARCIA"}, + notes="the boundary between the two exceptions: 'Y' is now " + "conjunction-tagged rather than initial-tagged, and the " + "three-word carve-out still refuses the join, so the " + "ROLE is unchanged. What moves is initials() -- " + "ParsedName.initials() gives 'J. G.' where 2.3 gave " + "'J. Y. G.', a conjunction contributing none " + "(rules.md#R3) -- while the v1 facade's " + "HumanName.initials() still gives 'J. Y. G.', the same " + "split the 'john e smith' row records, running the " + "other way " + "(test_facade_initials_do_not_yet_follow_the_one_case_fork " + "in tests/v2/test_render.py) -- which is why this row " + "needs the lowercase twin below to be readable", + shape=1), + Case("one_case_lower_y_keeps_the_three_word_carveout", "juan y garcia", + {"given": "juan", "middle": "y", "family": "garcia"}, + notes="the twin of the row above, unchanged in every release: " + "lowercase 'y' was already the connective at three " + "words, and the carve-out already kept it a name word", + shape=1), + Case("one_case_upper_cyrillic_connective_joins", "ХОСЕ И МАРИЯ САНТОС", + {"given": "ХОСЕ И МАРИЯ", "family": "САНТОС"}, + classification="feat(#269)", + notes="#267's blessing survives the #383 rewrite, and for a " + "different reason than it had: 'И' joins because 'и' is " + "not in conjunctions_ambiguous, not because the veto " + "tested a LATIN shape. No report -- the reading is not " + "in doubt. 1.4.0 has no Cyrillic conjunction vocabulary " + "at all and reads 'И' as a middle word (first ХОСЕ, " + "middle И МАРИЯ, last САНТОС -- measured on the 1.4.0 " + "wheel); #269 is what joins it, and this PR only adds " + "the one-case spellings to the roster #269 already " + "ships", + shape=1), + Case("one_case_lower_cyrillic_connective_joins", "хосе и мария сантос", + {"given": "хосе и мария", "family": "сантос"}, + classification="feat(#269)", + notes="the lowercase spelling of the row above; both cases " + "read alike, which is the point of making the rule " + "about evidence rather than about capitals. 1.4.0 reads " + "'и' as a middle word here too (measured on the 1.4.0 " + "wheel: first хосе, middle и мария, last сантос) for " + "the same reason -- no Cyrillic conjunction vocabulary " + "-- and #269 is what joins it", + shape=1), + Case("caseless_connective_never_enters_the_fork", "محمد و علي", + {"given": "محمد", "middle": "و", "family": "علي"}, + notes="Arabic و has no case at all, so the fork's cased-token " + "test is false and today's rule stands whatever the " + "name's case class is. The first standalone و in any " + "corpus -- measured 2026-09-13, none held one -- and " + "the three-word carve-out keeps it a name word, as it " + "does for 'juan y garcia'. shape=1 measured accepted by " + "Case.__post_init__ (2026-09-13): the row instantiates " + "shape 1's given-first arrangement under the default " + "order, exactly as the Cyrillic twins above do -- a " + "shape tag asserts the ARRANGEMENT, not a script " + "(tools/differential/shapes.py)", + shape=1), + Case("catalan_i_is_not_connective_vocabulary_upper", + "JOSEP CAROD I ROVIRA", + {"given": "JOSEP", "middle": "CAROD I", "family": "ROVIRA"}, + notes="pinned at TODAY's reading so #397 shows its move: 'i' " + "is not in CONJUNCTIONS at all, so the bare capital is " + "an initial by shape and this row never reaches the " + "fork. When #397 adds 'i' it ships in " + "conjunctions_ambiguous too, and this row changes", + shape=1), + Case("catalan_i_is_not_connective_vocabulary_lower", + "josep carod i rovira", + {"given": "josep", "middle": "carod i", "family": "rovira"}, + notes="the lowercase twin: 'i' is an ordinary name word, not " + "vocabulary, so nothing joins and nothing reports. This " + "row pins NOTHING today -- both readings are what every " + "release including 1.4.0 already gives -- and is kept " + "anyway as the other half of #397's before-picture, " + "beside its upper twin above", + shape=1), Case("family_comma_lenient_trailing", "Smith, John V", {"given": "John", "family": "Smith", "suffix": "V"}, notes="v1 #144: the trailing piece of a two-part comma name " diff --git a/tests/v2/pipeline/test_classify.py b/tests/v2/pipeline/test_classify.py index 69b73916..f063e55a 100644 --- a/tests/v2/pipeline/test_classify.py +++ b/tests/v2/pipeline/test_classify.py @@ -1,3 +1,5 @@ +import dataclasses + from nameparser._lexicon import Lexicon from nameparser._pipeline._classify import classify from nameparser._pipeline._extract import extract_delimited @@ -5,6 +7,7 @@ from nameparser._pipeline._state import ParseState from nameparser._pipeline._tokenize import tokenize from nameparser._policy import Policy +from nameparser._types import AmbiguityKind, Role _LEX = Lexicon( titles=frozenset({"dr", "sir"}), @@ -14,21 +17,29 @@ suffix_acronyms_ambiguous=frozenset({"ma"}), particles=frozenset({"de", "la", "van"}), particles_ambiguous=frozenset({"van"}), - # й 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", "й"}), + # 'e' and 'y' are both shipped single-letter conjunctions and the + # fork treats them differently, which is the whole point of the + # subset; й is COPIED from the shipped conjunctions (Ukrainian, + # #267) so the collision + # test_cyrillic_initial_outranks_the_conjunction pins is one that + # really ships and the reader can check against the defaults. The + # copies are local: this file never reads the shipped sets. + conjunctions=frozenset({"and", "e", "y", "й"}), + conjunctions_ambiguous=frozenset({"e"}), bound_given_names=frozenset({"abdul"}), maiden_markers=frozenset({"née"}), ) -def _classified(text: str) -> ParseState: - state = ParseState(original=text, lexicon=_LEX, policy=Policy()) +def _classified_with(text: str, lexicon: Lexicon) -> ParseState: + state = ParseState(original=text, lexicon=lexicon, policy=Policy()) return classify(segment(tokenize(extract_delimited(state)))) +def _classified(text: str) -> ParseState: + return _classified_with(text, _LEX) + + def _tags(state: ParseState, text: str) -> frozenset[str]: return next(t.tags for t in state.tokens if t.text == text) @@ -96,3 +107,164 @@ def test_bare_ambiguous_acronym_in_acronyms_is_not_suffix() -> None: out = _classified("Ma M.A.") assert "vocab:suffix" not in _tags(out, "Ma") assert "vocab:suffix" in _tags(out, "M.A.") + + +def test_one_case_subset_member_is_an_initial_and_reports() -> None: + # rules.md#P3 says a one-case name carries no case evidence, so + # the vocabulary decides, and 'e' is marked as reading both ways. + # (No colon after the rule id on purpose: tests/ is swept by + # test_doc_citations.py, and the colon form must quote the rule + # verbatim and puts this file on P3's implemented: list.) + for text in ("jose e maria santos", "JOSE E MARIA SANTOS"): + out = _classified(text) + letter = "e" if text.islower() else "E" + assert "initial" in _tags(out, letter), text + assert "conjunction" not in _tags(out, letter), text + kinds = [a.kind for a in out.ambiguities] + assert kinds == [AmbiguityKind.CONJUNCTION_OR_INITIAL], text + assert out.ambiguities[0].indices == (1,), text + assert repr(letter) in out.ambiguities[0].detail, text + + +def test_one_case_non_member_joins_even_as_a_bare_capital() -> None: + # the half that changes for 'y': a bare capital used to be vetoed + # into an initial by its Latin shape, and now joins, because the + # rule is about EVIDENCE and an all-upper name has none. + out = _classified("JUAN GARCIA Y LOPEZ") + assert "conjunction" in _tags(out, "Y") + assert "initial" not in _tags(out, "Y") + assert out.ambiguities == () + + +def test_mixed_case_keeps_todays_rule_verbatim() -> None: + # both halves, unchanged and unreported: a bare capital is an + # initial, a lowercase letter is the connective. + upper = _classified("Jose E Maria Santos") + assert "initial" in _tags(upper, "E") + assert "conjunction" not in _tags(upper, "E") + lower = _classified("John e Smith") + assert "conjunction" in _tags(lower, "e") + assert "initial" not in _tags(lower, "e") + assert upper.ambiguities == () and lower.ambiguities == () + + +def test_a_trailing_uppercase_suffix_makes_the_name_mixed_case() -> None: + # the gate reads the whole name's text, so 'john e jones, III' is + # mixed case and keeps today's reading -- the boundary that keeps + # a v1 corpus name still. + out = _classified("john e jones, III") + assert "conjunction" in _tags(out, "e") + assert out.ambiguities == () + + +def test_emptying_the_subset_restores_joining_for_e() -> None: + # the knob, and the reason there is no switch: remove 'e' and a + # one-case name joins it again, silently. + lex = dataclasses.replace(_LEX, conjunctions_ambiguous=frozenset()) + out = _classified_with("jose e maria santos", lex) + assert "conjunction" in _tags(out, "e") + assert "initial" not in _tags(out, "e") + assert out.ambiguities == () + + +def test_an_orphan_marker_is_inert_even_in_the_emitter() -> None: + # 'e' left in conjunctions_ambiguous but removed from conjunctions + # -- legal since the pair is not in _SUBSET_FIELDS (decisions.md#P3) + # -- never takes the fork, so 'E' in an all-upper name falls to the + # else branch and is tagged "initial" by is_initial() alone, same as + # any bare capital. Without the emitter's base-vocabulary gate this + # would still report a connective that the lexicon no longer has. + orphan = dataclasses.replace( + _LEX, conjunctions=_LEX.conjunctions - {"e"}) + out = _classified_with("JOSE E MARIA SANTOS", orphan) + assert "initial" in _tags(out, "E") + assert "conjunction" not in _tags(out, "E") + assert out.ambiguities == () + + +def test_a_caseless_connective_never_enters_the_fork() -> None: + # Arabic و has no case, so token.upper() == token.lower() and + # today's rule stands whatever the name's case class is. + lex = dataclasses.replace( + _LEX, conjunctions=_LEX.conjunctions | frozenset({"و"})) + out = _classified_with("محمد و علي", lex) + assert "conjunction" in _tags(out, "و") + assert out.ambiguities == () + + +def test_a_multi_letter_connective_is_untouched() -> None: + out = _classified("john and jane smith") + assert "conjunction" in _tags(out, "and") + assert out.ambiguities == () + + +def test_a_dotted_letter_is_an_initial_by_shape_in_any_case_class() -> None: + # 'e.' is initial-SHAPED, so the fork's len(text) == 1 gate declines + # and today's rule takes it -- initial, unreported, as it always was. + out = _classified("john e. smith") + assert "initial" in _tags(out, "e.") + assert "conjunction" not in _tags(out, "e.") + assert out.ambiguities == () + + +def test_a_maiden_clause_does_not_count_toward_the_case_class() -> None: + # rules.md#P3 says a maiden marker, taken as one, and the words it + # takes, are not among the name's own words -- so appending + # ' née Jones' must not flip 'Y' back to an initial: the name's own + # words ("JUAN GARCIA Y LOPEZ") are still all-upper on their own, + # and 'née Jones' being Title-case is not evidence about THEM. + out = _classified("JUAN GARCIA Y LOPEZ née Jones") + assert "conjunction" in _tags(out, "Y") + assert "initial" not in _tags(out, "Y") + assert out.ambiguities == () + + +def test_a_maiden_clause_leaves_the_own_words_report_intact() -> None: + # the other half: a one-case name's own words still report, clause + # appended or not. + out = _classified("jose e maria santos née jones") + assert "initial" in _tags(out, "e") + assert "conjunction" not in _tags(out, "e") + kinds = [a.kind for a in out.ambiguities] + assert kinds == [AmbiguityKind.CONJUNCTION_OR_INITIAL] + + +def test_a_delimited_clause_does_not_count_toward_the_case_class() -> None: + # delimited (nickname) content arrives with its role already set by + # extract, before classify ever runs, so it is excluded from the + # name's own words the same way a maiden clause is -- confirmed by + # inspecting the token here (role is Role.NICKNAME, not None). + out = _classified('JOSE E MARIA SANTOS "Pepe"') + pepe = next(t for t in out.tokens if t.text == "Pepe") + assert pepe.role is Role.NICKNAME + assert "initial" in _tags(out, "E") + kinds = [a.kind for a in out.ambiguities] + assert kinds == [AmbiguityKind.CONJUNCTION_OR_INITIAL] + + +def test_the_fork_itself_never_reads_a_delimited_clauses_tokens() -> None: + # the other half of the bug the above test alone did not catch: not + # just the CASE CLASS but the fork itself must skip a clause's own + # tokens, or a nickname's bare capital gets read as though it were + # one of the name's own words. 'Y' is initial-SHAPED, so pre-fork + # (2.3.0) it read "initial" and nothing else -- that must hold + # whatever the surrounding name's case class is. + y = _classified('JOSE MARIA SANTOS "Y"') + assert "initial" in _tags(y, "Y") + assert "conjunction" not in _tags(y, "Y") + assert y.ambiguities == () + e = _classified('JOSE MARIA SANTOS "E"') + assert "initial" in _tags(e, "E") + assert "conjunction" not in _tags(e, "E") + assert e.ambiguities == () + + +def test_a_word_after_the_maiden_marker_reads_as_plain_vocabulary() -> None: + # symmetric with the delimited-clause case: a word inside the + # maiden clause itself is not one of the name's own words either, + # so it never enters the fork -- 'e' after 'née' is bare vocabulary + # membership, the same reading it had before this PR. + out = _classified("juan garcia lopez née e") + assert "conjunction" in _tags(out, "e") + assert "initial" not in _tags(out, "e") + assert out.ambiguities == () diff --git a/tests/v2/pipeline/test_vocab.py b/tests/v2/pipeline/test_vocab.py index 53db2029..83903dc8 100644 --- a/tests/v2/pipeline/test_vocab.py +++ b/tests/v2/pipeline/test_vocab.py @@ -4,9 +4,9 @@ from nameparser._lexicon import Lexicon, _normalize, _title_key from nameparser._pipeline._vocab import ( - effective_script, is_initial, is_initial_shaped, is_suffix_lenient, - is_suffix_strict, is_wholly_suffix, maiden_marker_run, - resolve_script_set, single_script, + effective_script, is_initial, is_initial_shaped, is_one_case, + is_suffix_lenient, is_suffix_strict, is_wholly_suffix, + maiden_marker_run, resolve_script_set, single_script, ) from nameparser._policy import (Policy, Script, _NO_INITIALS, _SCRIPT_RANGES) @@ -510,3 +510,22 @@ def test_script_ranges_are_pairwise_disjoint() -> None: assert hi < other_lo or other_hi < lo, ( f"{script} range ({lo:#x}, {hi:#x}) overlaps {other} " f"range ({other_lo:#x}, {other_hi:#x})") + + +def test_is_one_case() -> None: + assert is_one_case(["jose", "e", "maria", "santos"]) + assert is_one_case(["JOSE", "E", "MARIA", "SANTOS"]) + assert not is_one_case(["Jose", "e", "Maria", "Santos"]) + assert not is_one_case(["john", "e", "jones", "III"]) + # a caseless script is "one case" harmlessly: the fork that reads + # this ALSO requires a cased token, so a caseless letter never + # enters it (rules.md#P3, decisions.md#P3) + assert is_one_case(["محمد", "و", "علي"]) + assert is_one_case(["山田", "太郎"]) + # the empty and single-token edges + assert is_one_case([]) + assert is_one_case(["e"]) + # the comparison is over the SPACE-JOINED text, R5's own gate, so a + # token that is caseless does not break a Latin name's verdict + assert is_one_case(["john", "e", "山田"]) + assert not is_one_case(["John", "e", "山田"]) diff --git a/tests/v2/test_contracts.py b/tests/v2/test_contracts.py index bd46a38c..5466ecf4 100644 --- a/tests/v2/test_contracts.py +++ b/tests/v2/test_contracts.py @@ -9,9 +9,9 @@ _AMBIGUITY_TRIGGERS: dict[AmbiguityKind, str | None] = { AmbiguityKind.PARTICLE_OR_GIVEN: "Van Johnson", - # no emitter yet -- arrives with the classify fork in this same PR - # (#383/#479); flipped to "JOSE E MARIA SANTOS" there - AmbiguityKind.CONJUNCTION_OR_INITIAL: None, + # #479 row 1, all-upper: 'E' is a marked single-letter connective + # and the name's one case says nothing about which reading is meant + AmbiguityKind.CONJUNCTION_OR_INITIAL: "JOSE E MARIA SANTOS", AmbiguityKind.UNBALANCED_DELIMITER: 'Jon "Nick Smith', AmbiguityKind.COMMA_STRUCTURE: "Smith, John, Extra, Jr.", AmbiguityKind.SUFFIX_OR_NICKNAME: "JEFFREY (JD) BRICKEN", diff --git a/tests/v2/test_ledger_guards.py b/tests/v2/test_ledger_guards.py index 9a24336a..39720c9a 100644 --- a/tests/v2/test_ledger_guards.py +++ b/tests/v2/test_ledger_guards.py @@ -355,7 +355,9 @@ def test_script_ranges_membership_is_decided() -> None: }), "expected_since_2.1.0.toml": frozenset(), # 2.2 cycle: no span-bearing rule "expected_since_2.2.0.toml": frozenset(), # 2.3 cycle: no span-bearing rule - "expected_since_2.3.0.toml": frozenset(), # open cycle, no rules yet + # The open cycle's rules (#383/#479, 2026-09-13) are literal + # names or alternations of names, and copy no script range. + "expected_since_2.3.0.toml": frozenset(), } #: The leading `fix(...)`/`feat(...)` tag of a rule's `issue`, which is @@ -879,12 +881,31 @@ def test_case_shape_ids_exist_in_the_inventory() -> None: # rather than be absorbed as per-word grouping. That is what the # case-sensitivity buys, and it is why this roster keeps probing # for it after the bug is gone. + # + # Re-read 2026-09-13 against #383/#479 and left exactly as it is. + # The case-sensitivity argument is about the #462 shapes, which + # are MIXED case, and this change touched only one-case names. The + # sentence above still holds literally for both uppercase probes: + # measured at 1.4.0 that day, neither 'JOSE E MARIA SANTOS' nor + # 'Jose E Maria Santos' diffs at all, the facade's initials being + # what this baseline compares and #383/#479 moving only the core's. "a connective run initials": ("Jose E Maria Santos", "JOSE E MARIA SANTOS", "Scott E. Werner", "Amy E Maid"), # fix(#462)'s boundary: lowercase bare e/y is the connective; # 'E.T.' is a run of initials the rule has no view on; a bare I # is not conjunction vocabulary at all. + # + # 2026-09-13: 'john e smith' stopped being a name that does not + # diff. #383/#479 makes it report conjunction-or-initial at every + # 2.x baseline and moves the CORE's initials as well, so this + # probe now guards a LIVE diff rather than a dormant shape -- and + # that is a stronger reason to keep it, not a reason to re-read + # it as stale. If this rule ever lost its lowercase exclusion the + # regex would reach the name, and the only thing left refusing the + # claim would be the field narrowing (`_initials` against a + # measured `_ambiguities`), which is a thinner wall than the + # regex and would vanish the moment the report stopped moving. "fix(#462)": ("john e smith", "maria y lopez", "E.T. Smith", "Maier, Amy I, Jr."), # #436/#437's rules are literal-anchored, so _CORPUS_CLAIMS' @@ -1029,6 +1050,51 @@ def test_case_shape_ids_exist_in_the_inventory() -> None: # abbreviation the initialless-script veto never touches. "fix(#322/#323)": ("김민준 씨.", "田中さん 様.", "김민준, 씨.", "Smith. John"), + # #383/#479's three rules are literal-anchored alternations, so + # _CORPUS_CLAIMS cannot see a widening that reaches only names the + # corpora lack -- these probes are the wall, and they are keyed by + # the FULL issue string because "fix(#383/#479)" matches three + # rules and each one's boundary is another's claim. + # + # Almost every probe is a MIXED-CASE spelling of a name the rule + # does claim. Mixed case is where the writing itself decides the + # letter (rules.md#P3), nothing moved there at all, and a rule that + # ever reached one would be absorbing a regression in the half of + # P3 this change did not touch. The rest are one-case names in the + # same population whose ROLES this change leaves alone -- 'JUAN + # Y GARCIA' keeps given, middle and family while its tag and its + # initials both move, which is why it probes the role rule. + "fix(#383/#479) a single-letter connective joins only on case evidence": + ("Jose e Maria Santos", "Jose E Maria Santos", + "Juan Garcia y Lopez", "Juan Garcia Y Lopez", "John e Smith", + "juan garcia y lopez", "JUAN Y GARCIA"), + # 'john e jones, III' is the probe worth understanding: the + # trailing uppercase III makes the whole name mixed-case, so it + # keeps today's reading and no rule of this change may ever claim + # it. 'johnny y' and 'der, y van' are one-case names carrying an + # unmarked letter, which reports nothing. 'e and e' is NOT a probe: + # this rule claims it at 2.3.0 and leaves it to feat(#449) at the + # three older baselines. + "fix(#383/#479) a marked connective letter in a one-case name is reported": + ("John e Smith", "John E Smith", "john e. smith", + "john e jones, III", "johnny y", "der, y van"), + # The view-only rule's boundary: the mixed-case spelling where the + # capital Y is an initial and stays one, the all-lower spelling + # that already read this way, and the sibling name whose ROLES move + # (the first rule above claims that one). + "fix(#383/#479) a bare capital connective in an all-upper name stops initialing": + ("Juan Y Garcia", "juan y garcia", "JUAN GARCIA Y LOPEZ", + "juan q. xavier velasquez y garcia iii"), + # The third feat(#269) rule, and the only one keyed on a derived + # view. Its boundary is the other two: the prefix chain and the + # Cyrillic pair are #269 recognitions as well, and both move ROLES, + # so an alternation that grew to reach them would be taking names + # off the rules that describe what actually happened to them. + "feat(#269) a recognized non-Latin connective contributes no initial": + ("محمد بن سلمان", + "ХОСЕ И МАРИЯ САНТОС", + "хосе и мария сантос", + "محمد و علي السيد"), } @@ -1968,6 +2034,25 @@ class _LatinCopy(NamedTuple): # whole rule claims, with a digest, so a widened regex fails on # the count or the digest, and _MUST_NOT_MATCH names the readings # the bundle deliberately left alone. + # + # #383/#479's movers, one corpus name per alternative -- lists of + # names, not copies of CONJUNCTIONS. The rule's subject is a SHAPE + # the vocabulary participates in twice over: the name must be + # written wholly in one case, and the letter must be (or not be) a + # `conjunctions_ambiguous` member. A member drawn from either + # wordlist would reach every corpus name carrying a connective at + # all -- 'Juan y Eva Garcia', 'juan garcia y lopez', 'Rob And Beth + # Edmunds' -- and seventeen names are one-case with a cased + # single-letter connective while only ten of them move. Three sets + # because the ledgers group the movers differently: the role pair + # is the same in all five, and the report rule claims 'e and e' at + # 2.3.0 alone, feat(#449) having it at the three older baselines. + frozenset({"jose e maria santos", "JUAN GARCIA Y LOPEZ"}), + frozenset({"JOSE E MARIA SANTOS", "JOHN E SMITH", "john e smith", + "john e jones", "jones, john e", "e j smith"}), + frozenset({"JOSE E MARIA SANTOS", "JOHN E SMITH", "john e smith", + "john e jones", "jones, john e", "e j smith", + "e and e"}), }) def _unjustified_reach(name_regex: str, members: set[str]) -> list[str]: @@ -2535,8 +2620,19 @@ def _claim(rule: dict) -> _Claim: _Claim(40, ('family', 'given', 'suffix'), "4d7bacfc28a4", None), "fix(cjk-honorific-suffix) postnominal honorifics recognized, compounding with the CJK order flip": _Claim(19, ('family', 'given', 'middle', 'suffix'), "aa475ddd4745", None), + # 4 -> 6 on 2026-09-13: 'ХОСЕ И МАРИЯ САНТОС' and 'хосе и + # мария сантос' entered corpus_shapes.jsonl with #383/#479's + # case rows, as the one-case control for a script whose + # connective the vocabulary has decided since 2.0. Both move + # ROLES against this baseline, which read 'И' as a middle + # word, so the pair is this rule's rather than #383/#479's -- + # the fork RUNS on them (one-case, and 'И'/'и' is a cased + # single letter) and takes the non-member branch, so the tag + # is the one the old path produced and no report is emitted, + # and nothing about their reading moved in 2.4. Reach and + # explanation both, unusually: the run classifies six here. "feat(#269) non-Latin titles/conjunctions recognized": - _Claim(4, ('given', 'middle', 'title'), "e86eeb13eeb2", None), + _Claim(6, ('given', 'middle', 'title'), "20cde5535c9a", None), "fix(#424) an unlisted abbreviation is as transparent as a listed title to the leading particle": _Claim(1, ('family', 'given'), "ca7b37af6cf8", None), "fix(#367) a title no longer displaces a leading particle out of the leading position": @@ -2639,12 +2735,28 @@ def _claim(rule: dict) -> _Claim: _Claim(27, ('_initials',), "6b242c287db8", ('DEFAULT',)), "fix(#360) los joined the particles, so it no longer initials": _Claim(1, ('_initials',), "cd721215f463", ('DEFAULT',)), + # #269's derived-view rule, added 2026-09-13. One corpus name, + # `_initials` alone: 'محمد و علي' entered the corpora with + # #383/#479's case rows and brought a 2.0-era view change with + # it -- a recognized connective contributes no initial -- which + # nothing had classified because no corpus name had exercised + # it. Literal and caseless, so a second name here means the + # alternation grew. + "feat(#269) a recognized non-Latin connective contributes no initial": + _Claim(1, ('_initials',), "770ce7374f32", ('DEFAULT',)), # 96 -> 97 on 2026-09-08: 'Prince of Wales Jr' joined the # rules corpus with the 2.3 title-run bundle -- a parity # row, kept as the boundary the peel floor declines -- and # `of` is a connective. Reach, not explanation. + # 97 -> 101 on 2026-09-13: 'john e smith', 'jose e maria + # santos', 'John e Smith' and 'juan y garcia' arrived with + # #383/#479's case rows and rules.md#P3 examples, each + # carrying a lowercase connective this regex reaches. Reach, + # not explanation again: the facade's view does not move for + # any of them at this baseline, which is why #383/#479 has no + # `_initials` rule here at all. "fix(initials-per-word) a connective run initials each word (facade, since 2.0.0)": - _Claim(97, ('_initials',), "6af5338ad4d5", ('DEFAULT',)), + _Claim(101, ('_initials',), "e91031622dca", ('DEFAULT',)), "fix(initials-per-word) a bound-given run initials each word (facade, since 2.0.0)": _Claim(41, ('_initials',), "e99f56c955d5", ('DEFAULT',)), "fix(initials-per-word) a particle chain inside a name part initials each word (facade, since 2.0.0)": @@ -2708,6 +2820,18 @@ def _claim(rule: dict) -> _Claim: "fix(#322/#323) a full stop on a CJK token is read as punctuation and stays on its token": _Claim(11, ('family', 'given', 'middle', 'suffix', 'title'), "671c6c89cf61", None), + # #383/#479's role rule, last in this ledger as in the file. + # TWO corpus names, `family`, `given` and `middle` together -- + # and the two names move DISJOINT pairs of those three, which + # is why the declaration is their union and why a widening + # taking a fourth role would change this row before it reached + # the gate. The regex is a literal alternation of the two, so + # a THIRD name appearing here means the alternation grew: + # seventeen corpus names sit in the class it describes and + # fifteen of them do not move a role. + "fix(#383/#479) a single-letter connective joins only on case evidence": + _Claim(2, ('family', 'given', 'middle'), "ec00806a06f0", + ('DEFAULT',)), }, "expected_since_2.0.0.toml": { # #436/#437's Latin alternation, first in every ledger. @@ -2945,8 +3069,12 @@ def _claim(rule: dict) -> _Claim: # re-roles the whole run into the FAMILY group, and it moves. # The digest is the same in all three 2.x ledgers because the # regex is the same string in each. + # 18 -> 22 on 2026-09-13: four #383/#479 names entered the + # corpora carrying a bare capital E or Y this regex reaches. + # expected_since_2.0.0.toml's copy of this rule says which + # four, and why only one of them diffs. "fix(#462) the facade keeps an initial-shaped conjunction letter": - _Claim(18, ('_initials',), "3dd0e0276be6", ('DEFAULT',)), + _Claim(22, ('_initials',), "7386690d2928", ('DEFAULT',)), # The 2.3 title-run bundle's five rules, last in every # ledger, and the same reach at all four: the 1.4.0 roster # above carries the argument. @@ -2983,6 +3111,24 @@ def _claim(rule: dict) -> _Claim: "fix(#322/#323) a full stop on a CJK token is read as punctuation and stays on its token": _Claim(11, ('_ambiguities', 'family', 'given', 'middle', 'suffix', 'title'), "671c6c89cf61", None), + # #383/#479's two rules, last in this ledger as in the file. + # The role rule reaches TWO corpus names and declares four + # fields, which is the union of two disjoint diffs plus the + # report ONE of them carries -- 'jose e maria santos' moves + # `given` and `middle` and reports, 'JUAN GARCIA Y LOPEZ' + # moves `middle` and `family` and reports nothing. Its digest + # is the 1.4.0 rule's, the same literal alternation over the + # same corpora; its roles are not, the report being a v2 + # surface. The report rule reaches SIX, `_ambiguities` alone: + # every role is identical on all six and only the CALL is new. + # Both regexes are literal alternations, so a new name here + # means one of them grew -- seventeen corpus names sit in the + # class they describe and only ten of those move at all. + "fix(#383/#479) a single-letter connective joins only on case evidence": + _Claim(2, ('_ambiguities', 'family', 'given', 'middle'), + "ec00806a06f0", ('DEFAULT',)), + "fix(#383/#479) a marked connective letter in a one-case name is reported": + _Claim(6, ('_ambiguities',), "03de2830707e", ('DEFAULT',)), }, # The 2.3 cycle's first rule, and a facade-only render fix: every # role is identical, so `_initials` alone. Reach and digest as in @@ -3072,8 +3218,12 @@ def _claim(rule: dict) -> _Claim: _Claim(1, ('suffix',), "6edfa4394c33", None), "fix(#436/#437) the glued honorific and the generational suffix are one post-nominal run": _Claim(1, ('suffix',), "1b67339cf744", None), + # 18 -> 22 on 2026-09-13: four #383/#479 names entered the + # corpora carrying a bare capital E or Y this regex reaches. + # expected_since_2.0.0.toml's copy of this rule says which + # four, and why only one of them diffs. "fix(#462) the facade keeps an initial-shaped conjunction letter": - _Claim(18, ('_initials',), "3dd0e0276be6", ('DEFAULT',)), + _Claim(22, ('_initials',), "7386690d2928", ('DEFAULT',)), # The 2.3 title-run bundle's five rules, last in every # ledger, and the same reach at all four: the 1.4.0 roster # above carries the argument. @@ -3113,6 +3263,24 @@ def _claim(rule: dict) -> _Claim: "fix(#322/#323) a full stop on a CJK token is read as punctuation and stays on its token": _Claim(17, ('_ambiguities', 'family', 'given', 'middle', 'suffix', 'title'), "ef4a7afe791a", None), + # #383/#479's two rules, last in this ledger as in the file. + # The role rule reaches TWO corpus names and declares four + # fields, which is the union of two disjoint diffs plus the + # report ONE of them carries -- 'jose e maria santos' moves + # `given` and `middle` and reports, 'JUAN GARCIA Y LOPEZ' + # moves `middle` and `family` and reports nothing. Its digest + # is the 1.4.0 rule's, the same literal alternation over the + # same corpora; its roles are not, the report being a v2 + # surface. The report rule reaches SIX, `_ambiguities` alone: + # every role is identical on all six and only the CALL is new. + # Both regexes are literal alternations, so a new name here + # means one of them grew -- seventeen corpus names sit in the + # class they describe and only ten of those move at all. + "fix(#383/#479) a single-letter connective joins only on case evidence": + _Claim(2, ('_ambiguities', 'family', 'given', 'middle'), + "ec00806a06f0", ('DEFAULT',)), + "fix(#383/#479) a marked connective letter in a one-case name is reported": + _Claim(6, ('_ambiguities',), "03de2830707e", ('DEFAULT',)), }, "expected_since_2.1.0.toml": { # #436/#437's Latin alternation, first in every ledger. @@ -3330,8 +3498,12 @@ def _claim(rule: dict) -> _Claim: # fix(#462), reach and digest as in the 2.0.0 mapping: the same # regex over the same corpora, and the facade bug it fixes is # in every 2.x wheel, so the baseline makes no difference. + # 18 -> 22 on 2026-09-13: four #383/#479 names entered the + # corpora carrying a bare capital E or Y this regex reaches. + # expected_since_2.0.0.toml's copy of this rule says which + # four, and why only one of them diffs. "fix(#462) the facade keeps an initial-shaped conjunction letter": - _Claim(18, ('_initials',), "3dd0e0276be6", ('DEFAULT',)), + _Claim(22, ('_initials',), "7386690d2928", ('DEFAULT',)), # The 2.3 title-run bundle's five rules, last in every # ledger, and the same reach at all four: the 1.4.0 roster # above carries the argument. @@ -3365,8 +3537,49 @@ def _claim(rule: dict) -> _Claim: "fix(#322/#323) a full stop on a CJK token is read as punctuation and stays on its token": _Claim(17, ('_ambiguities', 'family', 'given', 'middle', 'suffix', 'title'), "ef4a7afe791a", None), + # #383/#479's two rules, last in this ledger as in the file. + # The role rule reaches TWO corpus names and declares four + # fields, which is the union of two disjoint diffs plus the + # report ONE of them carries -- 'jose e maria santos' moves + # `given` and `middle` and reports, 'JUAN GARCIA Y LOPEZ' + # moves `middle` and `family` and reports nothing. Its digest + # is the 1.4.0 rule's, the same literal alternation over the + # same corpora; its roles are not, the report being a v2 + # surface. The report rule reaches SIX, `_ambiguities` alone: + # every role is identical on all six and only the CALL is new. + # Both regexes are literal alternations, so a new name here + # means one of them grew -- seventeen corpus names sit in the + # class they describe and only ten of those move at all. + "fix(#383/#479) a single-letter connective joins only on case evidence": + _Claim(2, ('_ambiguities', 'family', 'given', 'middle'), + "ec00806a06f0", ('DEFAULT',)), + "fix(#383/#479) a marked connective letter in a one-case name is reported": + _Claim(6, ('_ambiguities',), "03de2830707e", ('DEFAULT',)), + }, + "expected_since_2.3.0.toml": { + # #383/#479's three rules, the first this ledger carries. The + # role rule is the 2.x shape of the 1.4.0 rule of the same + # name -- two corpus names, the union of two disjoint role + # diffs plus the report one of them carries -- and its digest + # is that rule's, the same literal alternation over the same + # corpora. The report rule reaches SEVEN here where it reaches + # six at the older baselines: 'e and e' is in the alternation + # only in this ledger, feat(#449) claiming it at 2.0.0-2.2.0, + # so the count and the digest both differ by that one name. + # The view rule reaches ONE and exists only here, fix(#462) + # explaining the same name's `_initials` move at the three + # older baselines where the facade moves too. All three are + # literal alternations, so a new name in any of them means the + # alternation grew; seventeen corpus names sit in the class + # they describe and ten of those move. + "fix(#383/#479) a single-letter connective joins only on case evidence": + _Claim(2, ('_ambiguities', 'family', 'given', 'middle'), + "ec00806a06f0", ('DEFAULT',)), + "fix(#383/#479) a marked connective letter in a one-case name is reported": + _Claim(7, ('_ambiguities',), "2eb6eff33836", ('DEFAULT',)), + "fix(#383/#479) a bare capital connective in an all-upper name stops initialing": + _Claim(1, ('_initials',), "7ff29af96914", ('DEFAULT',)), }, - "expected_since_2.3.0.toml": {}, # open cycle, no rules yet } @@ -3468,7 +3681,10 @@ def test_every_rule_claims_the_recorded_share_of_the_corpus() -> None: #: of each fact, since two means one of them goes quietly stale. _CROSS_RULE_WINNERS: dict[str, dict[str, str]] = { "expected_since_2.2.0.toml": {}, - # open cycle: no rules, so no contest + # The open cycle carries #383/#479's three rules since + # 2026-09-13 and still no contest: literal names or alternations + # of names, reaching no corpus name in common, so no diff is + # claimed twice. "expected_since_2.3.0.toml": {}, "expected_since_1.4.0.toml": { # Spelled out since #508: the bare `fix(comma-family)` this row @@ -4979,7 +5195,10 @@ def test_a_rule_reaching_no_corpus_name_says_why_it_is_kept() -> None: ], "expected_since_2.1.0.toml": [], "expected_since_2.2.0.toml": [], - "expected_since_2.3.0.toml": [], # open cycle, no rules yet + # The open cycle's three rules (#383/#479, 2026-09-13) nest with + # nothing: no two of them share a corpus name, so no pair is a + # contest at all, let alone a wide-first one. + "expected_since_2.3.0.toml": [], } diff --git a/tests/v2/test_render.py b/tests/v2/test_render.py index 17bdee60..f56131ea 100644 --- a/tests/v2/test_render.py +++ b/tests/v2/test_render.py @@ -658,3 +658,38 @@ def test_render_malformed_specs_surface_raw_format_errors() -> None: pn.render("{}") with pytest.raises(ValueError): pn.render("{given!q}") + + +def test_capitalized_one_case_connective_that_reads_as_an_initial() -> None: + """#383/#479, the half no differential gate can see (decisions.md#R4). + + Repair lowercases a CONJUNCTION even inside a part it otherwise + capitalizes (rules.md#R4's carve-out). Once a marked single letter + in a one-case name is read as an INITIAL instead, that carve-out no + longer reaches it and the letter capitalizes like any name word. + The 'y' line is the control: it stays the connective, so it stays + lowercase, and the two together show the carve-out itself is + untouched. + """ + assert str(parse("john e jones").capitalized()) == "John E Jones" + assert str(parse("john e smith").capitalized()) == "John E Smith" + assert str(parse("juan garcia y lopez").capitalized()) \ + == "Juan Garcia y Lopez" + # R5's gate: mixed-case input is the writer's choice and repair + # defers to it, so this one is not repaired at all + assert str(parse("John e Smith").capitalized()) == "John e Smith" + + +def test_facade_initials_do_not_yet_follow_the_one_case_fork() -> None: + """The core's initials() follows the parse's tags: 'e' in a + one-case name is an INITIAL (rules.md#P3), so R3's "each given, + middle, and base family word" reaches it and it initials. + HumanName.initials() does not go through the parse at all -- + `_facade._process_initial` re-derives "conjunction" from the + lexicon and the part's raw text/shape, not from the token's tag -- + so it keeps 1.4.0 parity here. The split is recorded at + decisions.md#P3 and closing it is a follow-up issue's job, not + this one's. + """ + assert parse("john e smith").initials() == "j. e. s." + assert HumanName("john e smith").initials() == "j. s." diff --git a/tools/differential/compare.py b/tools/differential/compare.py index 9c2e62fc..76a684bb 100644 --- a/tools/differential/compare.py +++ b/tools/differential/compare.py @@ -1747,7 +1747,10 @@ class _ShapeMismatch(NamedTuple): #: diffs from nothing. _RECORDED_DIFFS: dict[str, dict[str, tuple[str, ...]]] = { "expected_since_2.2.0.toml": {}, - # open cycle: no rules, so nothing for a second one to contest + # The open cycle carries #383/#479's three rules since + # 2026-09-13, and no contest among them: each is a literal name + # or alternation of names, and no two reach the same corpus + # name, so the run finds no contested diff to adjudicate. "expected_since_2.3.0.toml": {}, "expected_since_1.4.0.toml": { "Andrews, M.D.": ("given", "suffix"), @@ -1934,8 +1937,8 @@ class _ShapeMismatch(NamedTuple): #: 'Carod i' diffs under the default order at 1.4.0 only, where its #: contest row stands, and 'MD, PHD' carries a contest row at every one #: of the three baselines it diffs at since #501 pinned its 2.x pair. -#: That is why the population is 51 names where the tests/-only scan -#: says 53. +#: That is why the population is 48 names where the tests/-only scan +#: says 50. #: Recounted 2026-09-07 with #342, which moved three names across the #: literal clause at once: 'Aishwarya Rai' gained a case row and left #: the population, while 'Lala Lajpat Rai' and 'John Smith, RAI' are @@ -1955,6 +1958,29 @@ class _ShapeMismatch(NamedTuple): #: that list to twenty-three, 'Sir Jr' leaving rules.md and so the #: rules corpus; it was a cases.py literal and never in this #: population, so the row counts are unmoved by that too. +#: Recounted 2026-09-13 with #383/#479, which moved three names across +#: the literal clause the way 'Aishwarya Rai' moved across it in #342: +#: 'Jose e Maria Santos' and 'JOSE E MARIA SANTOS' gained cases.py +#: rows and entered the CONTRACT corpora with them (corpus_rules.jsonl +#: and corpus_shapes.jsonl), and 'Juan Garcia y Lopez' became a string +#: literal in tests/v2/test_render.py -- so all three fail the literal +#: clause, two of them the radar-corpus clause as well, and all three +#: left the population. FIVE rows were RETIRED with them: 'Jose e +#: Maria Santos' and 'Juan Garcia y Lopez' at 1.4.0, and 'JOSE E MARIA +#: SANTOS' at 2.0.0, 2.1.0 and 2.2.0 -- that last row having been +#: re-recorded ("_initials",) -> ("_ambiguities",) earlier the same +#: day, before the population question was put to it. Nothing goes +#: unwatched by the retirement, which is the Aishwarya precedent's +#: whole point: each name now carries a case row asserting its entire +#: parse, and the three #383/#479 rules classify every diff the five +#: rows recorded. +#: One row the recount does NOT retire is worth naming, because +#: without it the per-file line below reads as a partition it is not: +#: 'QC MP' sits in corpus_rules.jsonl, a CONTRACT corpus, as well as +#: in corpus_issues.jsonl, and has done since before this change. By +#: the population clause above it does not belong here. It is left +#: alone rather than retired blind -- retiring a row removes a guard, +#: and nothing in this change made this one wrong. #: 'John Smith Rev.' is named NOWHERE under #: tests/, so it #: counts in both scans -- the every-file figures in the RECOMPUTE @@ -1962,14 +1988,15 @@ class _ShapeMismatch(NamedTuple): #: else: it did not re-derive the population clause above, so the #: equality sentence that follows is dated 2026-09-07 and is not #: restated for today. -#: The counts: 38 / 34 / 33 / 8 rows, 113 in all, over those 52 names +#: The counts: 36 / 33 / 32 / 7 rows, 108 in all, over those 49 names #: -- and as of 2026-09-07 the roster was exactly the population, the #: five contest rows beyond it having gone to _RECORDED_DIFFS with #501 #: and five more with #498, which left the population by gaining a #: _RECORDED_DIFFS key rather than by ceasing to be watched anywhere. -#: 50 of the 52 sit in corpus_issues.jsonl and 3 in corpus.jsonl, with -#: 'dr Vincent van Gogh dr' in both, so the per-file counts overlap by -#: one and are not a partition. Every row is a default-order shape, +#: 47 of the 49 sit in corpus_issues.jsonl, 3 in corpus.jsonl and 1 in +#: corpus_rules.jsonl, with 'dr Vincent van Gogh dr' and 'QC MP' each +#: in two of them, so the per-file counts overlap by two and are not a +#: partition. Every row is a default-order shape, #: as the roster above's are, so no row here is a declared-order-only #: diff for NOT CHECKED to name. #: @@ -1978,14 +2005,14 @@ class _ShapeMismatch(NamedTuple): #: calls whose order is None and whose rule is not None; apply the #: four clauses above with the literal set from ast.walk over #: tests/**/*.py EXCLUDING test_ledger_guards.py, as the POPULATION -#: clause says -- run over every file it yields 25 / 23 / 22 / 5 rows -#: rather than 38 / 34 / 33 / 8, since _CROSS_RULE_WINNERS' keys and +#: clause says -- run over every file it yields 24 / 23 / 22 / 5 rows +#: rather than 36 / 33 / 32 / 7, since _CROSS_RULE_WINNERS' keys and #: a few guard literals then score as watchers, and #498's fourteen #: keys are exactly that kind of literal -- as are #342's two #: 2026-09-07 arrivals, both named in _NOT_A_VOCABULARY_COPY. The #: every-file figures are the strict ones MINUS the roster names that #: are named as an exact string literal in test_ledger_guards.py and -#: nowhere else under tests/ (13 / 11 / 11 / 3 today), which is a +#: nowhere else under tests/ (12 / 10 / 10 / 2 today), which is a #: derivation a reader can run in one pass over this dict and that #: file -- no baseline wheel needed -- and it is how the pair was #: recomputed on 2026-09-09. That recount RETRACTS the pair recorded @@ -2040,8 +2067,6 @@ class _ShapeMismatch(NamedTuple): "John of the Doe": ("_initials",), "Jong van der": ("_initials",), "Jong, van der": ("_initials",), - "Jose e Maria Santos": ("_initials",), - "Juan Garcia y Lopez": ("_initials",), "Lala Lajpat Rai": ("family", "middle", "suffix"), "Mesnil Garcia van": ("_initials",), "Mohamad X": ("family", "suffix"), @@ -2074,7 +2099,6 @@ class _ShapeMismatch(NamedTuple): "Dr. Do Van Johnson, MD": ("family", "given"), "E Anne D,Leonardo": ("_initials",), "Esq. van Gogh": ("_ambiguities", "family", "given"), - "JOSE E MARIA SANTOS": ("_initials",), "Jane van der Berg 旧姓 Jones": ("family", "maiden"), "Janey née Jones": ("family", "given"), "Joe E. Smith": ("_initials",), @@ -2116,7 +2140,6 @@ class _ShapeMismatch(NamedTuple): "Dr. Do Van Johnson, MD": ("family", "given"), "E Anne D,Leonardo": ("_initials",), "Esq. van Gogh": ("_ambiguities", "family", "given"), - "JOSE E MARIA SANTOS": ("_initials",), "Jane van der Berg 旧姓 Jones": ("family", "maiden"), "Janey née Jones": ("family", "given"), "Joe E. Smith": ("_initials",), @@ -2144,7 +2167,6 @@ class _ShapeMismatch(NamedTuple): }, "expected_since_2.2.0.toml": { "E Anne D,Leonardo": ("_initials",), - "JOSE E MARIA SANTOS": ("_initials",), "Joe E. Smith": ("_initials",), "John Smith Rev.": ("family", "middle", "title"), "John Smith, RAI": ("family", "given", "suffix"), @@ -2152,7 +2174,12 @@ class _ShapeMismatch(NamedTuple): "Lala Lajpat Rai": ("family", "middle", "suffix"), "Smith, John E, III, Jr": ("_initials",), }, - "expected_since_2.3.0.toml": {}, # open cycle, no watched name yet + # Still empty on 2026-09-13, and now for a reason rather than for + # want of diffs: all TEN of the open cycle's movers are named by a + # string literal under tests/ outside test_ledger_guards.py -- a + # cases.py row, a render pin or a v1 bank -- so not one of them + # meets the population's literal clause and none takes a row here. + "expected_since_2.3.0.toml": {}, } diff --git a/tools/differential/corpus_rules.jsonl b/tools/differential/corpus_rules.jsonl index 44d6bb2a..b46c21ce 100644 --- a/tools/differential/corpus_rules.jsonl +++ b/tools/differential/corpus_rules.jsonl @@ -61,6 +61,7 @@ "J. Smith" "J. née Jones Smith V" "J.R. Smith" +"JUAN GARCIA Y LOPEZ" "Jack Ma." "Jack Wei Ma" "Jane (née Jones) Smith" @@ -128,6 +129,7 @@ "Jong, Anke de" "Jong, Piet de" "Jose E Maria Santos" +"Jose e Maria Santos" "Jr." "Juan & Garcia" "Juan McDonald" @@ -249,8 +251,11 @@ "de la Vega y Santos Juan" "de los Santos" "ibn Awf abdul Rahman" +"john e smith" "john smith phd" +"jose e maria santos" "juan de la vega" +"juan garcia y lopez" "juan mcdonald" "mohamad ali smith" "née Jones" diff --git a/tools/differential/corpus_shapes.jsonl b/tools/differential/corpus_shapes.jsonl index 9eba6d93..aaff9d87 100644 --- a/tools/differential/corpus_shapes.jsonl +++ b/tools/differential/corpus_shapes.jsonl @@ -1,12 +1,29 @@ {"name": "Dr. Juan de la Vega III", "shape": 1} +{"name": "JOHN E SMITH", "shape": 1} +{"name": "JOSE E MARIA SANTOS", "shape": 1} +{"name": "JOSEP CAROD I ROVIRA", "shape": 1} +{"name": "JUAN GARCIA Y LOPEZ", "shape": 1} +{"name": "JUAN Y GARCIA", "shape": 1} {"name": "John \"Jack\" Kennedy", "shape": 1} {"name": "John Jack Andrew Kennedy", "shape": 1} {"name": "John Smith", "shape": 1} {"name": "John Smith Jr.", "shape": 1} {"name": "John V. Smith", "shape": 1} +{"name": "John e Smith", "shape": 1} +{"name": "Jose E Maria Santos", "shape": 1} +{"name": "Jose e Maria Santos", "shape": 1} +{"name": "Juan Garcia Y Lopez", "shape": 1} {"name": "Juan de la Vega", "shape": 1} {"name": "Md Abdul Karim", "shape": 1} {"name": "Sir Bob Andrew Dole", "shape": 1} +{"name": "john e smith", "shape": 1} +{"name": "jose e maria santos", "shape": 1} +{"name": "josep carod i rovira", "shape": 1} +{"name": "juan garcia y lopez", "shape": 1} +{"name": "juan y garcia", "shape": 1} +{"name": "ХОСЕ И МАРИЯ САНТОС", "shape": 1} +{"name": "хосе и мария сантос", "shape": 1} +{"name": "محمد و علي", "shape": 1} {"name": "Beethoven, Ludwig van", "shape": 2} {"name": "Doe, John A.", "shape": 2} {"name": "Kennedy, John (Jack)", "shape": 2} diff --git a/tools/differential/expected_since_1.4.0.toml b/tools/differential/expected_since_1.4.0.toml index 3c71e711..6d0dbabe 100644 --- a/tools/differential/expected_since_1.4.0.toml +++ b/tools/differential/expected_since_1.4.0.toml @@ -2776,9 +2776,10 @@ fields = ["family", "suffix"] # on parse().initials() -- renders for a parse whose fields did not # move. # -# The order the file has them in: the two literal name LISTS first -# (fix(#385/#402), fix(#360)), then the three VOCABULARY rules -- -# connective, bound-given, particle chain -- then the Ph. D. merge. +# The order the file has them in: the three literal name LISTS first +# (fix(#385/#402), fix(#360), feat(#269)), then the three VOCABULARY +# rules -- connective, bound-given, particle chain -- then the Ph. D. +# merge. # Only the bound-given/particle-chain pair is ordered by narrowness, # and by measured reach rather than by reading: 41 corpus names # against 107 (measured 2026-09-02 at this baseline, and recorded in @@ -2791,6 +2792,18 @@ fields = ["family", "suffix"] # facade at all -- main() passes empty dicts for it -- so an # `_initials` diff under a declared order could only come from the # CORE, and must not be absorbed by a rule whose prose says "facade". +# +# #383/#479 has NO rule in this block, and the absence is a measured +# finding rather than an omission. The change moves what +# `parse(...).initials()` renders for 'john e smith', 'john e jones', +# 'jones, john e' and 'JUAN Y GARCIA' -- but `HumanName.initials()` +# re-derives the connective decision from vocabulary and initial shape +# on the raw text instead of reading the parse's tags, so the FACADE's +# view does not move at all, and the facade is the only surface +# compared below 2.0. Measured 2026-09-13: not one of those four names +# diffs here. The core's movement is classified at the 2.x baselines, +# where the core is compared (decisions.md#P3 records the facade split +# and the follow-up it owes). [[change]] issue = "fix(#385/#402) an all-particle name part initials its words (R2)" @@ -2823,6 +2836,34 @@ name_regex = "(?i)^de los santos$" fields = ["_initials"] orders = ["DEFAULT"] +[[change]] +issue = "feat(#269) a recognized non-Latin connective contributes no initial" +# 'محمد و علي': #269 (2.0) put the non-Latin connectives into +# CONJUNCTIONS, and `و` has been read as one ever since -- the roles +# agree with 1.4.0 word for word (given 'محمد', middle 'و', family +# 'علي'), because 1.4.0 already put the lone letter in the middle; +# what moved is that a connective contributes no initial +# (rules.md#R3), so 'م. و. ع.' became 'م. ع.'. That is the same +# recognition the two `feat(#269)` ROLE rules above classify, read +# from the derived view, and it needs a rule of its own because a +# rule listing `_initials` may list nothing else (#484). +# +# It is being classified now, three minors late, because the name +# only just entered a corpus: it is a rules.md#P3 example (the +# caseless control -- a script with one case carries no case evidence +# and never reaches the #383/#479 fork) and arrived with that rule's +# case-table rows on 2026-09-13. Nothing about it moves in 2.4; +# measured the same day, its parse and both its initials views are +# byte-identical before and after the fork. +# +# Literal, one name: the shape -- "a name whose only connective is a +# caseless one" -- reaches every Arabic and Cyrillic corpus name the +# moment one carries a `و` or an `и`, and the two Cyrillic names that +# do move here move their ROLES and belong to the rule above. +name_regex = "^محمد و علي$" +fields = ["_initials"] +orders = ["DEFAULT"] + [[change]] issue = "fix(initials-per-word) a connective run initials each word (facade, since 2.0.0)" # 'Juan y Eva Garcia', 'Dean of Chemistry Robert Johns', 'John & Jane': @@ -3272,3 +3313,68 @@ fields = ["family", "given", "suffix", "title"] issue = "fix(#322/#323) a full stop on a CJK token is read as punctuation and stays on its token" name_regex = "^(?:\\x28김민준\\.\\x29 John Smith|マイケル\\.|田中\\.|田中\\. 太郎|田中さん\\.|김민준 씨。|김민준 씨.|김민준 씨。|김민준\\.|김민준\\. 지훈|김민준씨\\.)$" fields = ["family", "given", "middle", "suffix", "title"] + +# --------------------------------------------------------------- +# #383/#479: A SINGLE-LETTER CONNECTIVE JOINS ONLY ON CASE EVIDENCE. +# One rule, and the count is the finding. Seventeen corpus names are +# written wholly in one case AND carry a cased single-letter +# connective -- the whole population this change can reach, measured +# 2026-09-13 over the 1174-name corpus by folding every one-letter +# token of every one-case name against CONJUNCTIONS -- and exactly +# two of them move a ROLE BECAUSE OF THIS CHANGE. Of the fifteen +# others, thirteen move no role at all and the Cyrillic pair move +# theirs for #269's reason -- vocabulary that has decided their 'и' +# since 2.0, nothing to do with the fork -- so four names in the +# population move a role against this baseline and only two of the +# four are this rule's. The other two are classified by +# `feat(#269) non-Latin titles/conjunctions recognized` above, whose +# reach the population grew by exactly those two. +# The report the change also emits is a v2 +# surface that does not exist below 2.0, while the derived view moved +# only in the CORE, which is not compared here either (the note in +# the `_initials` block above). So this is the whole of 2.4's +# #383/#479 blast radius at 1.4.0. +# +# LAST in the file: every diff it claims reported UNEXPLAINED on the +# run that preceded it (measured 2026-09-13), so no rule above +# already claims one, and nothing below can be shadowed by a literal +# alternation of two names. +# --------------------------------------------------------------- + +[[change]] +issue = "fix(#383/#479) a single-letter connective joins only on case evidence" +# 'jose e maria santos' and 'JUAN GARCIA Y LOPEZ', both rules.md#P3 +# examples. A name written wholly in one case carries no case +# evidence about any letter in it, so the reading comes from the +# vocabulary: 'e' is marked as reading both ways and reads as an +# initial, 'y' is not and joins. Both directions are v1 parity BREAKS +# and both are deliberate -- 1.4.0 gives 'jose e maria santos' first +# 'jose e maria' and 'JUAN GARCIA Y LOPEZ' middle 'GARCIA Y' +# (measured from the 1.4.0 wheel, 2026-09-13). See rules.md#P3 and +# the 2026-09-13 entry of decisions.md#P3. +# +# The two names move DISJOINT role pairs and the declaration is their +# union: 'jose e maria santos' moves `given` and `middle` (the joined +# run splits, and what is past the letter becomes the middle name), +# 'JUAN GARCIA Y LOPEZ' moves `middle` and `family` (the letter now +# joins, so the middle empties into the family). `#452`'s +# over-declaration check recomputes that union on every run, so a +# third role here would fail rather than stand as a claim on a future +# diff. +# +# Literal-anchored to the two names: the class is wide (every one-case +# name with a cased single-letter connective) but the corpora carry +# only these two at a role-moving shape, and a regex for the class +# would claim the fifteen OTHER names of that population (the section +# comment above counts them) whose roles this change does not move +# -- including 'juan garcia y lopez', which already joined, and +# 'JUAN Y GARCIA', whose three-word carve-out keeps the letter a name +# word in both directions. +# _MUST_NOT_MATCH in tests/v2/test_ledger_guards.py carries the probes, +# every one of them a MIXED-case spelling: mixed case is where the +# writing decides, nothing moved there at all, and a rule reaching one +# would be absorbing a regression in the half of rules.md#P3 this +# change did not touch. +name_regex = "^(?:jose e maria santos|JUAN GARCIA Y LOPEZ)$" +fields = ["given", "middle", "family"] +orders = ["DEFAULT"] diff --git a/tools/differential/expected_since_2.0.0.toml b/tools/differential/expected_since_2.0.0.toml index 809ab1f6..29e49c40 100644 --- a/tools/differential/expected_since_2.0.0.toml +++ b/tools/differential/expected_since_2.0.0.toml @@ -1780,11 +1780,13 @@ issue = "fix(#462) the facade keeps an initial-shaped conjunction letter" # read E as a conjunction, only the facade's render did -- so this is # `_initials` alone (#484). The shape: a bare capital E or Y, or a # dotted E./Y. of either case, standing as its own word. Lowercase -# bare e/y is the connective and does not move. The regex reaches 18 -# corpus names, of which 14 diffed when the rule was written -# (2026-09-02); _CORPUS_CLAIMS in tests/v2/test_ledger_guards.py pins -# that reach as _Claim(18, ...) and fails if it moves, so the REACH -# digit is checked rather than remembered. The 14 is not: it is a +# bare e/y is the connective and does not move. The regex reaches 22 +# corpus names -- 18 until #383/#479's case rows and rules.md#P3 +# examples arrived on 2026-09-13 -- of which 14 diffed when the rule +# was written (2026-09-02); _CORPUS_CLAIMS in +# tests/v2/test_ledger_guards.py pins that reach as _Claim(22, ...) +# and fails if it moves, so the REACH digit is checked rather than +# remembered. The 14 is not: it is a # dated snapshot of what diffed that day and nothing re-runs it. # The names it reaches and does not move are # what the gap IS, and the invariant behind it is a GROUP rather than @@ -1792,6 +1794,26 @@ issue = "fix(#462) the facade keeps an initial-shaped conjunction letter" # they carry the E/Y in the GIVEN group, which has always initialed # every word it holds whatever the vocabulary says, so there was # nothing for the fix to restore. +# +# 2026-09-13, #383/#479: 'JUAN Y GARCIA' now diffs here for TWO +# reasons and this rule explains both, because they are one +# `_initials` move. The FACADE's half is this rule's own -- a bare +# capital 'Y' is initial-shaped, so the 2.3 fix keeps it where this +# baseline dropped it ('J. G.' -> 'J. Y. G.', measured from the +# wheel). The CORE's half is new: in a name written wholly in one +# case nothing marks the letter as an initial, 'y' is not vocabulary +# that reads both ways, so it is the connective and contributes no +# initial ('J. Y. G.' -> 'J. G.', rules.md#P3 for which letters are +# connectives and rules.md#R3 for what a connective contributes). +# The two halves move the same pseudo-field in OPPOSITE directions on +# the two surfaces, and `_initials` is one field, so one rule takes +# it. That is why #383/#479's section at the end of this file writes +# no competing `_initials` rule -- an equal-`fields` contest decided +# by nothing but file order. At 2.3.0, where this rule does not +# exist because the facade fix has shipped, the core's half is +# classified on its own. +# This is the ONE full copy: the 2.1.0 and 2.2.0 ledgers and the +# three _CORPUS_CLAIMS entries point here rather than restating it. name_regex = "(?:^|[\\s,])(?:[EY]|[EeYy]\\.)(?=[\\s,]|$)" fields = ["_initials"] orders = ["DEFAULT"] @@ -2147,3 +2169,88 @@ fields = ["family", "given", "suffix", "title"] issue = "fix(#322/#323) a full stop on a CJK token is read as punctuation and stays on its token" name_regex = "^(?:\\x28김민준\\.\\x29 John Smith|マイケル\\.|田中\\.|田中\\. 太郎|田中さん\\.|김민준 씨。|김민준 씨.|김민준 씨。|김민준\\.|김민준\\. 지훈|김민준씨\\.)$" fields = ["_ambiguities", "family", "given", "middle", "suffix", "title"] + +# --------------------------------------------------------------- +# #383/#479: A SINGLE-LETTER CONNECTIVE JOINS ONLY ON CASE EVIDENCE. +# Two rules where 1.4.0 needs one, and the split is the pseudo-field +# rule (#484) rather than two behaviors: from 2.0 on the v2 surface +# is compared, so the new CONJUNCTION_OR_INITIAL report enters the +# diff -- on one of the two names whose roles move ('JUAN GARCIA Y +# LOPEZ' reports nothing, nothing about 'y' having been in doubt) +# and on six more whose roles do not -- and a role move and a +# report can co-occur while a role move and `_initials` cannot. +# +# The derived view is deliberately absent from both rules. The core's +# initials() DOES move for 'john e smith', 'john e jones' and +# 'jones, john e' ('j. j.' -> 'j. e. j.'), but `_initials` enters a +# diff only where every role AND every ambiguity kind agrees (#484, +# compare.py main()), and the report moved on each of them -- so at +# this baseline those names classify on the report alone. 'JUAN Y +# GARCIA' is the one name of the population whose view moves with no +# report beside it, and at this baseline it is already claimed: +# fix(#462) reaches it and admits `_initials`, because the FACADE's +# view moves there too (the 2.3 facade fix has not shipped at 2.0.0). +# That rule's comment records the double reason; a competing rule +# here would be an equal-`fields` contest decided by file order, +# which is what _CROSS_RULE_WINNERS exists to stop rather than to +# create. It is classified on its own at 2.3.0, where the facade +# agrees and only the core moves. +# +# LAST in the file: every diff these two claim reported UNEXPLAINED +# on the run that preceded them (measured 2026-09-13), so no rule +# above already claims one. +# --------------------------------------------------------------- + +[[change]] +issue = "fix(#383/#479) a single-letter connective joins only on case evidence" +# The 2.x view of the 1.4.0 rule of the same name: 'jose e maria +# santos' reads given 'jose', middle 'e maria' where 2.0.0 joined, +# and 'JUAN GARCIA Y LOPEZ' reads family 'GARCIA Y LOPEZ' where +# 2.0.0's bare-Latin-capital veto gave middle 'GARCIA Y'. A name +# written wholly in one case carries no case evidence about any +# letter in it, so the vocabulary decides: 'e' is marked as reading +# both ways and reads as an initial, 'y' is not and joins. Both are +# rules.md#P3 examples; see the 2026-09-13 entry of decisions.md#P3. +# +# `_ambiguities` is in the fields here and cannot be at 1.4.0 -- the +# report is a v2 surface -- and only ONE of the two names carries it: +# 'jose e maria santos' reports conjunction-or-initial, 'JUAN GARCIA +# Y LOPEZ' reports nothing, because nothing about 'y' was ever in +# doubt. The declaration is the union of the two diffs and #452's +# over-declaration check recomputes it every run. +# +# Literal-anchored to the two names, as at 1.4.0: the class is every +# one-case name carrying a cased single-letter connective, seventeen +# corpus names, and fifteen of them do not move a role. +# _MUST_NOT_MATCH carries the mixed-case probes. +name_regex = "^(?:jose e maria santos|JUAN GARCIA Y LOPEZ)$" +fields = ["given", "middle", "family", "_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#383/#479) a marked connective letter in a one-case name is reported" +# 'JOSE E MARIA SANTOS', 'JOHN E SMITH', 'john e smith', 'john e +# jones', 'jones, john e', 'e j smith': every role is identical -- +# the letter was already read as an initial, or rules.md#P3's +# three-word carve-out keeps it a name word either way -- and what is +# new is that the CALL is reported as conjunction-or-initial, because +# a one-case name gives the reader nothing to decide it by. +# +# The initials of 'john e smith' ('j. s.' -> 'j. e. s.'), 'john e +# jones' and 'jones, john e' (both 'j. j.' -> 'j. e. j.') move as +# well, and the view is deliberately not in the fields: see the +# section comment above for why no run can produce it here. +# +# 'e and e' is NOT in the alternation although it is in the same +# population and its report moves too (it LOSES given-or-family and +# gains conjunction-or-initial, both inside `_ambiguities`). At this +# baseline `feat(#449) a lone name word reports given-or-family` +# names it literally and admits the diff, so adding it here would +# create an equal-`fields` contest that only file order decides -- +# the class `precedes_narrower` cannot express and _CROSS_RULE_WINNERS +# has to pin. The name is radar tier, so nothing is hidden by leaving +# it where it is; 2.3.0's copy of this rule does claim it, that +# ledger carrying no #449 rule. +name_regex = "^(?:JOSE E MARIA SANTOS|JOHN E SMITH|john e smith|john e jones|jones, john e|e j smith)$" +fields = ["_ambiguities"] +orders = ["DEFAULT"] diff --git a/tools/differential/expected_since_2.1.0.toml b/tools/differential/expected_since_2.1.0.toml index 5d40d66d..7c5ee367 100644 --- a/tools/differential/expected_since_2.1.0.toml +++ b/tools/differential/expected_since_2.1.0.toml @@ -1701,11 +1701,13 @@ issue = "fix(#462) the facade keeps an initial-shaped conjunction letter" # read E as a conjunction, only the facade's render did -- so this is # `_initials` alone (#484). The shape: a bare capital E or Y, or a # dotted E./Y. of either case, standing as its own word. Lowercase -# bare e/y is the connective and does not move. The regex reaches 18 -# corpus names, of which 14 diffed when the rule was written -# (2026-09-02); _CORPUS_CLAIMS in tests/v2/test_ledger_guards.py pins -# that reach as _Claim(18, ...) and fails if it moves, so the REACH -# digit is checked rather than remembered. The 14 is not: it is a +# bare e/y is the connective and does not move. The regex reaches 22 +# corpus names -- 18 until #383/#479's case rows and rules.md#P3 +# examples arrived on 2026-09-13 -- of which 14 diffed when the rule +# was written (2026-09-02); _CORPUS_CLAIMS in +# tests/v2/test_ledger_guards.py pins that reach as _Claim(22, ...) +# and fails if it moves, so the REACH digit is checked rather than +# remembered. The 14 is not: it is a # dated snapshot of what diffed that day and nothing re-runs it. # The names it reaches and does not move are # what the gap IS, and the invariant behind it is a GROUP rather than @@ -1713,6 +1715,12 @@ issue = "fix(#462) the facade keeps an initial-shaped conjunction letter" # they carry the E/Y in the GIVEN group, which has always initialed # every word it holds whatever the vocabulary says, so there was # nothing for the fix to restore. +# +# 2026-09-13, #383/#479: 'JUAN Y GARCIA' now diffs here for TWO +# reasons at once -- the facade's, which is this rule's own, and the +# core's, which is new -- and this rule explains both as one +# `_initials` move. expected_since_2.0.0.toml's copy of this rule +# carries the account; it is the same at all three baselines. name_regex = "(?:^|[\\s,])(?:[EY]|[EeYy]\\.)(?=[\\s,]|$)" fields = ["_initials"] orders = ["DEFAULT"] @@ -2052,3 +2060,88 @@ fields = ["suffix", "title"] issue = "fix(#322/#323) a full stop on a CJK token is read as punctuation and stays on its token" name_regex = "^(?:\\x28김민준\\.\\x29 John Smith|マイケル\\.|田中\\.|田中\\. 太郎|田中さん\\.|田中さん\\., V\\.|김\\. 민준|김민준 씨。|김민준 씨.|김민준 씨。|김민준\\.|김민준\\. 지훈|김민준씨\\.|김민준씨\\., J\\.씨|양 지훈\\.|양\\. 지훈|이, J\\.씨\\.)$" fields = ["_ambiguities", "family", "given", "middle", "suffix", "title"] + +# --------------------------------------------------------------- +# #383/#479: A SINGLE-LETTER CONNECTIVE JOINS ONLY ON CASE EVIDENCE. +# Two rules where 1.4.0 needs one, and the split is the pseudo-field +# rule (#484) rather than two behaviors: from 2.0 on the v2 surface +# is compared, so the new CONJUNCTION_OR_INITIAL report enters the +# diff -- on one of the two names whose roles move ('JUAN GARCIA Y +# LOPEZ' reports nothing, nothing about 'y' having been in doubt) +# and on six more whose roles do not -- and a role move and a +# report can co-occur while a role move and `_initials` cannot. +# +# The derived view is deliberately absent from both rules. The core's +# initials() DOES move for 'john e smith', 'john e jones' and +# 'jones, john e' ('j. j.' -> 'j. e. j.'), but `_initials` enters a +# diff only where every role AND every ambiguity kind agrees (#484, +# compare.py main()), and the report moved on each of them -- so at +# this baseline those names classify on the report alone. 'JUAN Y +# GARCIA' is the one name of the population whose view moves with no +# report beside it, and at this baseline it is already claimed: +# fix(#462) reaches it and admits `_initials`, because the FACADE's +# view moves there too (the 2.3 facade fix has not shipped at 2.1.0). +# That rule's comment records the double reason; a competing rule +# here would be an equal-`fields` contest decided by file order, +# which is what _CROSS_RULE_WINNERS exists to stop rather than to +# create. It is classified on its own at 2.3.0, where the facade +# agrees and only the core moves. +# +# LAST in the file: every diff these two claim reported UNEXPLAINED +# on the run that preceded them (measured 2026-09-13), so no rule +# above already claims one. +# --------------------------------------------------------------- + +[[change]] +issue = "fix(#383/#479) a single-letter connective joins only on case evidence" +# The 2.x view of the 1.4.0 rule of the same name: 'jose e maria +# santos' reads given 'jose', middle 'e maria' where 2.1.0 joined, +# and 'JUAN GARCIA Y LOPEZ' reads family 'GARCIA Y LOPEZ' where +# 2.1.0's bare-Latin-capital veto gave middle 'GARCIA Y'. A name +# written wholly in one case carries no case evidence about any +# letter in it, so the vocabulary decides: 'e' is marked as reading +# both ways and reads as an initial, 'y' is not and joins. Both are +# rules.md#P3 examples; see the 2026-09-13 entry of decisions.md#P3. +# +# `_ambiguities` is in the fields here and cannot be at 1.4.0 -- the +# report is a v2 surface -- and only ONE of the two names carries it: +# 'jose e maria santos' reports conjunction-or-initial, 'JUAN GARCIA +# Y LOPEZ' reports nothing, because nothing about 'y' was ever in +# doubt. The declaration is the union of the two diffs and #452's +# over-declaration check recomputes it every run. +# +# Literal-anchored to the two names, as at 1.4.0: the class is every +# one-case name carrying a cased single-letter connective, seventeen +# corpus names, and fifteen of them do not move a role. +# _MUST_NOT_MATCH carries the mixed-case probes. +name_regex = "^(?:jose e maria santos|JUAN GARCIA Y LOPEZ)$" +fields = ["given", "middle", "family", "_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#383/#479) a marked connective letter in a one-case name is reported" +# 'JOSE E MARIA SANTOS', 'JOHN E SMITH', 'john e smith', 'john e +# jones', 'jones, john e', 'e j smith': every role is identical -- +# the letter was already read as an initial, or rules.md#P3's +# three-word carve-out keeps it a name word either way -- and what is +# new is that the CALL is reported as conjunction-or-initial, because +# a one-case name gives the reader nothing to decide it by. +# +# The initials of 'john e smith' ('j. s.' -> 'j. e. s.'), 'john e +# jones' and 'jones, john e' (both 'j. j.' -> 'j. e. j.') move as +# well, and the view is deliberately not in the fields: see the +# section comment above for why no run can produce it here. +# +# 'e and e' is NOT in the alternation although it is in the same +# population and its report moves too (it LOSES given-or-family and +# gains conjunction-or-initial, both inside `_ambiguities`). At this +# baseline `feat(#449) a lone name word reports given-or-family` +# names it literally and admits the diff, so adding it here would +# create an equal-`fields` contest that only file order decides -- +# the class `precedes_narrower` cannot express and _CROSS_RULE_WINNERS +# has to pin. The name is radar tier, so nothing is hidden by leaving +# it where it is; 2.3.0's copy of this rule does claim it, that +# ledger carrying no #449 rule. +name_regex = "^(?:JOSE E MARIA SANTOS|JOHN E SMITH|john e smith|john e jones|jones, john e|e j smith)$" +fields = ["_ambiguities"] +orders = ["DEFAULT"] diff --git a/tools/differential/expected_since_2.2.0.toml b/tools/differential/expected_since_2.2.0.toml index c3ae5386..5815102b 100644 --- a/tools/differential/expected_since_2.2.0.toml +++ b/tools/differential/expected_since_2.2.0.toml @@ -342,11 +342,13 @@ issue = "fix(#462) the facade keeps an initial-shaped conjunction letter" # read E as a conjunction, only the facade's render did -- so this is # `_initials` alone (#484). The shape: a bare capital E or Y, or a # dotted E./Y. of either case, standing as its own word. Lowercase -# bare e/y is the connective and does not move. The regex reaches 18 -# corpus names, of which 14 diffed when the rule was written -# (2026-09-02); _CORPUS_CLAIMS in tests/v2/test_ledger_guards.py pins -# that reach as _Claim(18, ...) and fails if it moves, so the REACH -# digit is checked rather than remembered. The 14 is not: it is a +# bare e/y is the connective and does not move. The regex reaches 22 +# corpus names -- 18 until #383/#479's case rows and rules.md#P3 +# examples arrived on 2026-09-13 -- of which 14 diffed when the rule +# was written (2026-09-02); _CORPUS_CLAIMS in +# tests/v2/test_ledger_guards.py pins that reach as _Claim(22, ...) +# and fails if it moves, so the REACH digit is checked rather than +# remembered. The 14 is not: it is a # dated snapshot of what diffed that day and nothing re-runs it. # The names it reaches and does not move are # what the gap IS, and the invariant behind it is a GROUP rather than @@ -354,6 +356,12 @@ issue = "fix(#462) the facade keeps an initial-shaped conjunction letter" # they carry the E/Y in the GIVEN group, which has always initialed # every word it holds whatever the vocabulary says, so there was # nothing for the fix to restore. +# +# 2026-09-13, #383/#479: 'JUAN Y GARCIA' now diffs here for TWO +# reasons at once -- the facade's, which is this rule's own, and the +# core's, which is new -- and this rule explains both as one +# `_initials` move. expected_since_2.0.0.toml's copy of this rule +# carries the account; it is the same at all three baselines. name_regex = "(?:^|[\\s,])(?:[EY]|[EeYy]\\.)(?=[\\s,]|$)" fields = ["_initials"] orders = ["DEFAULT"] @@ -703,3 +711,88 @@ fields = ["family", "given", "middle", "title"] issue = "fix(#322/#323) a full stop on a CJK token is read as punctuation and stays on its token" name_regex = "^(?:\\x28김민준\\.\\x29 John Smith|マイケル\\.|田中\\.|田中\\. 太郎|田中さん\\.|田中さん\\., V\\.|김\\. 민준|김민준 씨。|김민준 씨.|김민준 씨。|김민준\\.|김민준\\. 지훈|김민준씨\\.|김민준씨\\., J\\.씨|양 지훈\\.|양\\. 지훈|이, J\\.씨\\.)$" fields = ["_ambiguities", "family", "given", "middle", "suffix", "title"] + +# --------------------------------------------------------------- +# #383/#479: A SINGLE-LETTER CONNECTIVE JOINS ONLY ON CASE EVIDENCE. +# Two rules where 1.4.0 needs one, and the split is the pseudo-field +# rule (#484) rather than two behaviors: from 2.0 on the v2 surface +# is compared, so the new CONJUNCTION_OR_INITIAL report enters the +# diff -- on one of the two names whose roles move ('JUAN GARCIA Y +# LOPEZ' reports nothing, nothing about 'y' having been in doubt) +# and on six more whose roles do not -- and a role move and a +# report can co-occur while a role move and `_initials` cannot. +# +# The derived view is deliberately absent from both rules. The core's +# initials() DOES move for 'john e smith', 'john e jones' and +# 'jones, john e' ('j. j.' -> 'j. e. j.'), but `_initials` enters a +# diff only where every role AND every ambiguity kind agrees (#484, +# compare.py main()), and the report moved on each of them -- so at +# this baseline those names classify on the report alone. 'JUAN Y +# GARCIA' is the one name of the population whose view moves with no +# report beside it, and at this baseline it is already claimed: +# fix(#462) reaches it and admits `_initials`, because the FACADE's +# view moves there too (the 2.3 facade fix has not shipped at 2.2.0). +# That rule's comment records the double reason; a competing rule +# here would be an equal-`fields` contest decided by file order, +# which is what _CROSS_RULE_WINNERS exists to stop rather than to +# create. It is classified on its own at 2.3.0, where the facade +# agrees and only the core moves. +# +# LAST in the file: every diff these two claim reported UNEXPLAINED +# on the run that preceded them (measured 2026-09-13), so no rule +# above already claims one. +# --------------------------------------------------------------- + +[[change]] +issue = "fix(#383/#479) a single-letter connective joins only on case evidence" +# The 2.x view of the 1.4.0 rule of the same name: 'jose e maria +# santos' reads given 'jose', middle 'e maria' where 2.2.0 joined, +# and 'JUAN GARCIA Y LOPEZ' reads family 'GARCIA Y LOPEZ' where +# 2.2.0's bare-Latin-capital veto gave middle 'GARCIA Y'. A name +# written wholly in one case carries no case evidence about any +# letter in it, so the vocabulary decides: 'e' is marked as reading +# both ways and reads as an initial, 'y' is not and joins. Both are +# rules.md#P3 examples; see the 2026-09-13 entry of decisions.md#P3. +# +# `_ambiguities` is in the fields here and cannot be at 1.4.0 -- the +# report is a v2 surface -- and only ONE of the two names carries it: +# 'jose e maria santos' reports conjunction-or-initial, 'JUAN GARCIA +# Y LOPEZ' reports nothing, because nothing about 'y' was ever in +# doubt. The declaration is the union of the two diffs and #452's +# over-declaration check recomputes it every run. +# +# Literal-anchored to the two names, as at 1.4.0: the class is every +# one-case name carrying a cased single-letter connective, seventeen +# corpus names, and fifteen of them do not move a role. +# _MUST_NOT_MATCH carries the mixed-case probes. +name_regex = "^(?:jose e maria santos|JUAN GARCIA Y LOPEZ)$" +fields = ["given", "middle", "family", "_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#383/#479) a marked connective letter in a one-case name is reported" +# 'JOSE E MARIA SANTOS', 'JOHN E SMITH', 'john e smith', 'john e +# jones', 'jones, john e', 'e j smith': every role is identical -- +# the letter was already read as an initial, or rules.md#P3's +# three-word carve-out keeps it a name word either way -- and what is +# new is that the CALL is reported as conjunction-or-initial, because +# a one-case name gives the reader nothing to decide it by. +# +# The initials of 'john e smith' ('j. s.' -> 'j. e. s.'), 'john e +# jones' and 'jones, john e' (both 'j. j.' -> 'j. e. j.') move as +# well, and the view is deliberately not in the fields: see the +# section comment above for why no run can produce it here. +# +# 'e and e' is NOT in the alternation although it is in the same +# population and its report moves too (it LOSES given-or-family and +# gains conjunction-or-initial, both inside `_ambiguities`). At this +# baseline `feat(#449) a lone name word reports given-or-family` +# names it literally and admits the diff, so adding it here would +# create an equal-`fields` contest that only file order decides -- +# the class `precedes_narrower` cannot express and _CROSS_RULE_WINNERS +# has to pin. The name is radar tier, so nothing is hidden by leaving +# it where it is; 2.3.0's copy of this rule does claim it, that +# ledger carrying no #449 rule. +name_regex = "^(?:JOSE E MARIA SANTOS|JOHN E SMITH|john e smith|john e jones|jones, john e|e j smith)$" +fields = ["_ambiguities"] +orders = ["DEFAULT"] diff --git a/tools/differential/expected_since_2.3.0.toml b/tools/differential/expected_since_2.3.0.toml index 952867fa..411591c7 100644 --- a/tools/differential/expected_since_2.3.0.toml +++ b/tools/differential/expected_since_2.3.0.toml @@ -32,3 +32,132 @@ # There is deliberately no `change = []` line. TOML forbids appending a # [[change]] table to a statically defined array, so that line would # block the first entry. + +# --------------------------------------------------------------- +# #383/#479: A SINGLE-LETTER CONNECTIVE JOINS ONLY ON CASE EVIDENCE. +# The first rules this ledger carries, and the cleanest picture of +# the change there is: 2.3.0 is the previous release, so every diff +# below is 2.4's own and nothing else's. Ten names, three rules -- +# the two whose ROLES move, the seven that gain (or exchange) a +# REPORT, and the one whose derived VIEW moves alone. +# +# rules.md#P3 states the rule: a single letter reads as an initial +# where the writing says so -- written as a bare LATIN capital in a +# name that is not written wholly in one case, or, in a name written +# wholly in one case where nothing says so, where the letter is one +# the vocabulary marks as reading both ways +# (`Lexicon.conjunctions_ambiguous`, shipping `{"e"}`). So in a +# one-case name 'e' reads as an initial and reports the call, and +# 'y' joins as the connective it always was. Mixed case is untouched +# in both directions, which every _MUST_NOT_MATCH probe on these +# three rules pins. History: the 2026-09-13 entry of decisions.md#P3. +# +# The whole population this change can reach is the seventeen corpus +# names written wholly in one case that carry a cased single-letter +# connective (measured 2026-09-13 over the 1174-name corpus). +# Ten of the seventeen diff here. Of the seven that do not: +# 'juan garcia y lopez' and 'juan y garcia' already read that way; +# 'johnny y', 'der, y van' and 'juan q. xavier velasquez y garcia +# iii' DO reach the fork -- each is one-case and carries a cased +# single letter -- and it takes the non-member branch, 'y' being no +# `conjunctions_ambiguous` entry, which tags the letter a connective +# exactly as the old veto-free path did and emits no report; and the +# Cyrillic pair reads its 'и' through vocabulary that has decided it +# since 2.0. +# --------------------------------------------------------------- + +[[change]] +issue = "fix(#383/#479) a single-letter connective joins only on case evidence" +# The two names whose ROLES move. 'jose e maria santos' read given +# 'jose e maria' at 2.3.0 -- the connective joined its neighbours +# into one part -- and reads given 'jose', middle 'e maria' now that +# the letter is an initial. 'JUAN GARCIA Y LOPEZ' read middle +# 'GARCIA Y', family 'LOPEZ', because 2.3.0 vetoed a join on any +# single-letter connective written as a bare Latin capital; that veto +# is gone, so the connective joins and the family is 'GARCIA Y +# LOPEZ'. One rule and not two: it is one sentence of rules.md#P3 +# read in its two directions, and both names are examples of it. +# +# `_ambiguities` is in the fields because ONE of the two carries the +# new report: 'jose e maria santos' reports conjunction-or-initial, +# 'JUAN GARCIA Y LOPEZ' reports nothing, nothing about 'y' having +# been in doubt. The declaration is the union of the two diffs and +# #452's over-declaration check recomputes it on every run. +# +# Literal-anchored to the two names. The class is wide -- every +# one-case name carrying a cased single-letter connective -- and +# fifteen of its seventeen members do not move a role, so a regex for +# the class would stand ready to explain a regression on any of them. +# _MUST_NOT_MATCH in tests/v2/test_ledger_guards.py carries the +# probes, every one a MIXED-case spelling of a name this rule claims: +# mixed case is where the writing decides, nothing moved there, and a +# rule reaching one would be absorbing a regression in the half of +# rules.md#P3 this change did not touch. +name_regex = "^(?:jose e maria santos|JUAN GARCIA Y LOPEZ)$" +fields = ["given", "middle", "family", "_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#383/#479) a marked connective letter in a one-case name is reported" +# 'JOSE E MARIA SANTOS', 'JOHN E SMITH', 'john e smith', 'john e +# jones', 'jones, john e', 'e j smith', 'e and e': every role is +# identical -- the letter was already read as an initial, or +# rules.md#P3's three-word carve-out keeps it a name word either way +# -- and what is new is that the CALL is now reported as +# conjunction-or-initial, because a one-case name gives the reader +# nothing to decide it by. 'e and e' EXCHANGES a report rather than +# gaining one: its two 'e's become initial-tagged words, so O5's +# lone-word branch no longer fires and the given-or-family report it +# made at 2.3.0 is replaced by conjunction-or-initial. Its roles and +# its initials are unchanged. +# +# The core's initials() moves for 'john e smith' ('j. s.' -> 'j. e. +# s.'), 'john e jones' and 'jones, john e' (both 'j. j.' -> 'j. e. +# j.'), and `_initials` is deliberately absent from the fields: the +# derived view enters a diff only where every role AND every +# ambiguity kind agrees (#484, compare.py main()), and the report +# moved on each of them, so no run can produce a shape here carrying +# it. That movement is visible nowhere else either -- the v1 facade +# re-derives the connective decision from vocabulary and shape +# instead of reading the parse's tags, so `HumanName.initials()` does +# not move at all and the 1.4.0 +# ledger, which compares the facade alone, has nothing to classify. +# decisions.md#P3 records the split. +name_regex = "^(?:JOSE E MARIA SANTOS|JOHN E SMITH|john e smith|john e jones|jones, john e|e j smith|e and e)$" +fields = ["_ambiguities"] +orders = ["DEFAULT"] + +# --------------------------------------------------------------------- +# The `_initials` rule (#484) -- see the block of the same name at the +# end of expected_since_1.4.0.toml for what the pseudo-field is. One +# rule, because exactly one name of this change's population moves the +# derived view with no report beside it to suppress it. +# --------------------------------------------------------------------- + +[[change]] +issue = "fix(#383/#479) a bare capital connective in an all-upper name stops initialing" +# 'JUAN Y GARCIA': 'y' is not marked as reading both ways, so in a +# name written wholly in one case it joins rather than being vetoed +# into an initial -- and rules.md#P3's three-word carve-out then +# refuses the join, leaving given 'JUAN', middle 'Y', family 'GARCIA' +# exactly as they were. What moves is the derived view alone, 'J. Y. +# G.' -> 'J. G.', a connective contributing no initial (rules.md#R3). +# No report: nothing about 'y' was in doubt, which is why this name +# reaches `_initials` where the 'e' names do not. +# +# It has a rule of its own HERE and at no other baseline. At 2.0.0, +# 2.1.0 and 2.2.0 the same name also moves the FACADE's view, in the +# opposite direction and for the opposite reason (#462's fix keeps +# the initial-shaped capital the older facades dropped), and +# `_initials` is one field -- so `fix(#462)` explains both halves +# there as one move, and its comment in those three ledgers says so. +# That fix has shipped at this baseline, so the facade agrees and +# only the core moves. +# +# Literal, one name: the shape would be "an all-upper three-word name +# whose middle word is an unmarked single-letter connective", which +# reaches 'JUAN GARCIA Y LOPEZ' (roles, the first rule above) and +# 'juan y garcia' (unchanged) the moment it is written as a regex. +name_regex = "^JUAN Y GARCIA$" +fields = ["_initials"] +orders = ["DEFAULT"] From 2f34c22ba9d808e31c1e5586ca98d3cd1956e109 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Sun, 13 Sep 2026 16:47:02 -0700 Subject: [PATCH 3/6] docs(design+release): the evidence rule for single-letter connectives, recorded decisions.md#P3 closes the #383 Open block with a 2026-09-13 entry: BLESS for the mixed-case half, where a bare LATIN capital still decides -- `_INITIAL` is ASCII-only, which is why #267's Cyrillic reading stands untouched and the phrase "Latin-capital veto" does not retire -- and VOCABULARY for the one-case half, where nothing in the writing says which reading a letter takes. Also in that entry: both questions the rule asks are asked of the name's OWN words, with the declined-marker edge stated at its measured width (a maiden marker's run and every word after it leave the span the moment classify tags the marker, so a declined marker neither counts toward the case class nor lets the connectives after it into the one-case fork -- accepted rather than repaired, since classify cannot know what group will decline); the report is subset members only; there is no switch, the subset being the knob; a locale pack cannot express "remove e", so a pt caller uses Lexicon.remove; and the facade/core initials split is an Accepted consequence deferred to a follow-up issue, the fix having R4's shape. A second dated entry records that the pair is deliberately absent from `_SUBSET_FIELDS` -- an orphan is inert because the fork and its emitter both require the base entry -- citing the given-name-titles precedent, which two tests now point at. An Excluded block keyed to Lexicon.conjunctions_ambiguous says what must stay out of the subset and why (y, the Cyrillic letters, caseless letters, multi-letter entries), and an Open block keyed to Lexicon.conjunctions carries the question #397 actually leaves open: whether Catalan `i` belongs in the conjunctions at all. Two (A) bullets under 3-0-reevaluations park what the subset raised and did not answer -- whether a locale pack should be able to REMOVE base vocabulary, and that the differential harness parses every corpus under the default Lexicon (compare.py builds one Parser per name order and no corpus row carries a locale), so a pack's effect can never diff. decisions.md#R5 gains a dated amendment rather than an edit: the one-letter-conjunction mechanism behind "uppercase is the worse direction" is gone, `Velasquez y Garcia, Dr. Juan Q.` now agreeing forced, uppercased and lowercased, and the direction has reversed. The counts are re-measured with one recipe over the same 1174-name corpus on both sides -- core 69/23 at the 2.3.0 wheel against 11/18 here, facade 68/20 against 10/15 -- and the entry says the old 63/18 and 62/16 figures are a 1094-name snapshot that reproduces in neither direction today. rules.md#R5 needed no matching amendment: it never carried the claim, which was checked by reading rather than assumed. customize.rst names the new pair in the subset paragraph (three enforced, two deliberately unchecked with their reasons) and documents the set in both directions, with measured doctests for the Portuguese removal and the Dutch addition. docs/release_log.rst's 2.4.0 section gains the behavior-change bullet -- the two role moves, the mixed-case halves left alone, the derived views on the short names, the Cyrillic and Arabic non-moves -- plus Additions bullets for Lexicon.conjunctions_ambiguous and AmbiguityKind.CONJUNCTION_OR_INITIAL. AGENTS.md's shim-translation roster goes from six to seven, naming `conjunctions_ambiguous = CONJUNCTIONS_AMBIGUOUS & conjunctions` and the one v1 knob that reaches it, and its config data-module roster names the new constant beside CONJUNCTIONS. rules.md#R3's standing corpus count is re-measured 25 -> 26 with its recompute recipe attached, and rules.md#P3's stale "once #395 lands" is now present tense. config/conjunctions.py's comment beside the subset assert is corrected: the assert holds the SHIPPED constant only, and a caller's orphan is inert rather than rejected. Refs #383, #479 Co-Authored-By: Claude Fable 5.1 --- AGENTS.md | 4 +- docs/customize.rst | 76 +++++++++++++++++++++++++------ docs/design/decisions.md | 35 ++++++++++++-- docs/design/rules.md | 43 ++++++++++++----- docs/release_log.rst | 10 ++++ nameparser/config/conjunctions.py | 12 +++-- 6 files changed, 146 insertions(+), 34 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 92304477..64927c10 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -273,7 +273,7 @@ Most modules define a `frozenset` of known name pieces; `capitalization.py` and - `suffixes.py` — `SUFFIX_ACRONYMS` (with periods, e.g. "M.D.") and `SUFFIX_WORDS` (e.g. "Jr."), plus `GLUED_HONORIFICS` (#308), the subset of `SUFFIX_WORDS` the peel may split off the END of a name token — a separate, harsher set, since the glued position has no writer-drawn boundary to lean on - `particles.py` — `PARTICLES` (family-name particles, e.g. "de", "van") and `NON_GIVEN_NAME_PARTICLES`, the curated subset that is *never* a standalone given name (a name whose opening PIECE is one of them, standing alone, is all surname — "de Mesnil" — under EVERY `name_order` since #359, and the degenerate bare "de" with nothing to fold into still stays as it is. What post_rules rule 1b enforces is one clause wider than the leading shape, and reading it as leading-only is how the FAMILY_FIRST bug got in: where a member 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 fold into the family. Two shapes, one repair — opening the name it pulls the rest in, and in the given position (`"Mesnil de"` under `FAMILY_FIRST`, where the given position is the trailing piece) it folds into the family beside it. So the rule asks by opening POSITION, read off `pieces`, as well as by the GIVEN role; the role test alone caught both shapes only because under the default order the opening piece IS the given. It is a lone PIECE throughout, and stating it any wider is false: under `FAMILY_FIRST` the given position of `Juan de la Vega` holds the whole chain `de la Vega`, a three-token piece rather than a lone particle, so 1b declines and reports it — #359 records that case as working as intended — and the degenerate bare `de` keeps given `de` because it has nothing to fold into. (`Sir de Mesnil` reported given `de Mesnil` and no family at all in the default order, which was the same guard declining on a chained piece; #367 removed the chain rather than touching this rule — a title is now transparent to the leading-particle exception, so the piece is lone again, 1b fires, and the name reads family `de Mesnil` like the untitled form.) A leading particle OUTSIDE the set is genuinely order-dependent and still splits — "van Gogh" is family "van", given "Gogh" under both family-first orders — since a word that CAN be a given name leaves `name_order` a real question to answer; what the set decides under any of the three orders is that such a leading particle records a `PARTICLE_OR_GIVEN` ambiguity and one inside it records none); `Lexicon.particles_ambiguous` is its complement within `PARTICLES`, so the two mark OPPOSITE sets — see the flip warning in `docs/migrate.rst` before translating either - `bound_given_names.py` — `BOUND_GIVEN_NAMES` (bound given-name prefixes, e.g. "abdul", "abu"); a group-stage rule joins the first non-title piece to its following piece before roles are assigned, reserving a family word unless a family comma has already fixed it or a given-name title stands ahead (#369, rules.md#P5) (v1's `_join_bound_first_name`, ported into `_pipeline/_group.py` and gone from the tree — the v1 descriptions further down are history, not current code) -- `conjunctions.py` — `CONJUNCTIONS` (e.g. "and", "of") used to chain multi-word titles +- `conjunctions.py` — `CONJUNCTIONS` (e.g. "and", "of") used to chain multi-word titles and to join name parts (rules.md#P3), plus `CONJUNCTIONS_AMBIGUOUS` (#383/#479), the subset of single cased letters that read as an INITIAL in a name written wholly in one case — a marker set with no v1 `Constants` attribute of its own, so the only v1 knob that reaches it is deleting the conjunction - `maiden_markers.py` — `MAIDEN_MARKERS` (e.g. "née", "geb.") routing the following name to `maiden` - `surnames.py` — `KOREAN_SURNAMES`, the census list the 2.0 API splits unspaced hangul on (#271). With `maiden_markers.py` it is one of the two data modules `Constants` has **no** attribute for: both reach the parse only through `Constants._snapshot()` → `Lexicon`, so the v1 surface stays frozen and there is no v1 knob to turn either off (the opt-out is the 2.0 `Policy`) - `capitalization.py` — `CAPITALIZATION_EXCEPTIONS` mapping (e.g. `{'phd': 'Ph.D.'}`) @@ -317,7 +317,7 @@ The 2.0 rewrite lands as underscore-private modules alongside the v1 code. These - **Parser owns config-dependent conveniences**: `Parser.matches`/`Parser.capitalized`/`Parser.revise` exist because the `ParsedName` equivalents fall back to DEFAULT config for str/omitted arguments (documented loudly in both docstrings). `revise` harvests tokens from a full sub-parse of each replacement value (tags kept minus `FOLDED_TAG`, roles forced, the R1 entry pass `suffix_entries` re-run over the forced state so a suffix value's entries follow its own commas, ambiguities discarded); the merge tail is shared with `replace()` via `ParsedName._with_field_tokens`. `Parser.capitalized` delegates through `name.capitalized(self.lexicon)` specifically so `_parser` never imports `_render` — keep it that way. - **Per-word vocabulary fields warn on multi-word entries** (`_normset`/`_normpairs` via `_warn_dead_entry`, UserWarning, never a raise — see the given_name_titles Gotcha for why raising is wrong). `given_name_titles` is the one multi-word-matched field and is exempt; `_edit` passes `warn=False` (add() warns once via the new instance's `__post_init__`; remove() stores nothing). The default vocabulary and every locale pack must stay warning-free (`test_default_lexicon_builds_warning_free`, `test_pack_vocabulary_entries_are_single_words`). - **Invariants guard harm, not no-ops**: add a constructor check when violating it produces a *wrong parse*, not when it produces *nothing*. A false positive costs a working configuration; a true positive on an inert condition costs the user nothing, so that trade is never worth taking. `suffix_acronyms_ambiguous ∩ suffix_words` is guarded because the overlap loses a family name; `given_name_titles` is not, because an unreachable entry is simply never consulted (see Gotchas). Before adding one, construct the config it forbids and check what actually breaks. **Between raise and silence sits the construction-time `UserWarning`**, for a gap that is statically decidable, harmless to SOME deliberate caller, and indistinguishable-from-working for everyone else: the segmenterless activation (#337 — `parser_for(locales.JA)` without a segmenter behaved exactly like a working parser minus the feature) warns rather than raises because the inert JA registration is itself a pinned property, and a warning is filterable by the caller who wants exactly that. The message must carry every applicable remedy and no inapplicable one (the `ja_segmenter` hint fires only when a Japanese script is among the dead ones). Test fuzzers that legitimately construct such configs suppress the warning by MESSAGE, never by category — a blanket `UserWarning` ignore would mask the next construction diagnostic (`_quiet_parser` in `tests/v2/test_properties.py` is the pattern). -- **The shim TRANSLATES; it never raises on a config v1 accepted, and never silently changes the parse**: `Constants._snapshot()` is a translation boundary between v1's model and v2's invariants, and every transformation there carries its v1-reachability argument in a comment. Six exist today — `first_name_titles` re-folded per word (v1 joins-then-`lc`, v2 normalizes-then-joins), `suffix_acronyms_ambiguous ∩ acronyms` (a provable no-op), `suffix_words − ambiguous` (v1 already accepts the word via the acronym branch, so the addition is inert there), `particles_ambiguous ∪ (bound ∩ particles)` (a pinned deviation, `test_bound_never_given_prefix_deviates_on_two_pieces`), `honorific_tails = GLUED_HONORIFICS ∩ suffix_words` (#308 behavior with no v1 manager of its own, so the one v1 knob that reaches it is deleting the suffix word — which turns the peel off, `test_snapshot_removing_a_honorific_word_turns_the_peel_off`), and `maiden_delimiters − nickname_delimiters` on the POLICY half of the same method (v1 precedence: a pair in both v1 buckets parses as a nickname, while `Policy` resolves the overlap the other way, so the subtraction is what keeps the facade at v1 behavior, `test_snapshot_overlap_keeps_v1_nickname_precedence`). Note that last one is on the `Policy`, not the `Lexicon` — the roster is per-`_snapshot()`, not per-vocabulary-field, so a sweep that only reads the `Lexicon(...)` call misses it. When a v1 config cannot satisfy a v2 invariant, work out what v1 actually *does* with it — usually nothing — and reproduce that; weakening the invariant or letting the raise through are both wrong. **Test the case the translation decides**, not one where both branches agree: a test using an input v1 parses identically with and without the config pins nothing. +- **The shim TRANSLATES; it never raises on a config v1 accepted, and never silently changes the parse**: `Constants._snapshot()` is a translation boundary between v1's model and v2's invariants, and every transformation there carries its v1-reachability argument in a comment. Seven exist today — `first_name_titles` re-folded per word (v1 joins-then-`lc`, v2 normalizes-then-joins), `suffix_acronyms_ambiguous ∩ acronyms` (a provable no-op), `suffix_words − ambiguous` (v1 already accepts the word via the acronym branch, so the addition is inert there), `particles_ambiguous ∪ (bound ∩ particles)` (a pinned deviation, `test_bound_never_given_prefix_deviates_on_two_pieces`), `honorific_tails = GLUED_HONORIFICS ∩ suffix_words` (#308 behavior with no v1 manager of its own, so the one v1 knob that reaches it is deleting the suffix word — which turns the peel off, `test_snapshot_removing_a_honorific_word_turns_the_peel_off`), `conjunctions_ambiguous = CONJUNCTIONS_AMBIGUOUS ∩ conjunctions` (#383/#479 behavior with no v1 manager of its own either, so the one v1 knob that reaches it is deleting the conjunction — which turns the marking off, `test_snapshot_removing_a_conjunction_turns_the_marker_off`), and `maiden_delimiters − nickname_delimiters` on the POLICY half of the same method (v1 precedence: a pair in both v1 buckets parses as a nickname, while `Policy` resolves the overlap the other way, so the subtraction is what keeps the facade at v1 behavior, `test_snapshot_overlap_keeps_v1_nickname_precedence`). Note that last one is on the `Policy`, not the `Lexicon` — the roster is per-`_snapshot()`, not per-vocabulary-field, so a sweep that only reads the `Lexicon(...)` call misses it. When a v1 config cannot satisfy a v2 invariant, work out what v1 actually *does* with it — usually nothing — and reproduce that; weakening the invariant or letting the raise through are both wrong. **Test the case the translation decides**, not one where both branches agree: a test using an input v1 parses identically with and without the config pins nothing. - **Reprs are bounded**: render which fields deviate from a named baseline and by how much, never contents (`Lexicon(default + titles: +2)`). `PolicyPatch`'s repr shows only set (non-UNSET) fields; `_order_repr` must never raise even on an unvalidated patch's garbage `name_order` (PolicyPatch defers validation to apply time); the sweep test in `tests/v2/test_reprs.py` pins that no config repr leaks the UNSET sentinel. - **Every pipeline stage is one module holding one public function of the same name** (`_tokenize.py`/`tokenize`), with the exceptions named where they stand — `_group.py`'s `marker_run_length`, a shared predicate (mechanisms.md#ONE-PREDICATE-PER-QUESTION), and since #511 `_post_rules.py`'s `suffix_entries`, the R1 entry pass as a function, because `Parser.revise` runs that one pass over a forced-role sub-parse and a stage's tail cannot be called on its own — **and its module docstring declares the contract in three labelled lines**: `Consumes:` what it takes from `ParseState`, `Produces:` what it hands back, `Reads:` which `Policy`/`Lexicon` fields it consults. `Reads:` is the load-bearing one — it makes "which stage do I touch for this feature?" a grep rather than a read-through. The authority for the stage set is `_pipeline/__init__.py`'s `STAGES` (eight, and not public API) with the field-ownership map in `ParseState`'s docstring, pinned by `tests/v2/pipeline/test_state.py`; NOT rules.md, which is implementation-free by its own preamble and whose `implemented:` names modules honoring a rule rather than stages. A `_pipeline/` module that is not a stage says so in its first line instead (`_assemble.py`: "Not a stage: …", omitting `Reads:` because it consults neither), so absence of the three lines is a claim about the module rather than an oversight. Provenance: §5 of the 2026-07-11 conventions spec, recorded here 2026-08-16. - **A claim about WHICH STAGE or WHICH LAYER does something is checkable — check it before writing it.** The pipeline is eight stages with a written ownership map (`ParseState`'s docstring, pinned by `tests/v2/pipeline/test_state.py`), and `parse(s).tokens` prints every token's role and tags, so "extract assigns this", "classify never sees that", "group consumes it" each have a one-command answer. #329's prose claimed delimited maiden content is *"claimed whole before classify has tagged anything inside it"*; measured, `classify` tags the marker fine and only the CONSUMING is missing, because `_group`'s rule walks `pieces` and a token that already carries a role is not in `pieces`. Two different mechanisms, one plausible sentence covering both. That single claim then shipped SIX times across three correction rounds, which is the part worth internalizing: **when a mechanism claim turns out wrong, sweep for where else you wrote it, and sweep again at the END of the change over the words the change itself just added.** Prose density here means one idea lives in a docstring, a case note, a release-log entry and this file at once; the implementer working against a wrong mechanism is the person most likely to restate it; and rewriting a mechanism claim is writing one, so the correction earns the same one-command check as the original — two of the six instances were fresh errors introduced by the sentence fixing the previous one. **Adjacency is the trap.** The claim that feels already-known is the one about the neighbouring stage or the neighbouring layer: one comment block in `tests/v2/test_facade_cases.py` got the exception type, the raising layer, the skip mechanism, the count of skipped rows, and which row was blocked all wrong at once — every one a claim about `_config_shim` versus real 1.4.0, written from reasoning, in the file whose whole job is translating between them. What finally held was not better prose but moving the claim into a test (`_CORE_ONLY_IDS`), which cannot be wrong the way a sentence can. diff --git a/docs/customize.rst b/docs/customize.rst index aa900db1..68ec3383 100644 --- a/docs/customize.rst +++ b/docs/customize.rst @@ -99,15 +99,20 @@ Removing works the same way, and drops the word from recognition: A few fields mark a subset of another — ``given_name_titles`` over ``titles``, ``particles_ambiguous`` over ``particles``, -``suffix_acronyms_ambiguous`` over ``suffix_acronyms``, and +``suffix_acronyms_ambiguous`` over ``suffix_acronyms``, +``conjunctions_ambiguous`` over ``conjunctions``, and ``honorific_tails`` over ``suffix_words``. Entries belong in the base -field too, so add to both and remove from the marker first. The last -three enforce that: anything else raises ``ValueError`` naming the -orphans rather than leaving a marker entry that no rule will ever -consult. ``given_name_titles`` is deliberately unchecked — a title run -is matched as one space-joined string, or by that run's last word, so a -legitimate entry like ``"sir and dame"`` is no single word in -``titles`` — and an orphan there is inert rather than harmful. +field too, so add to both and remove from the marker first. Three of +them enforce that — ``particles_ambiguous``, ``suffix_acronyms_ambiguous`` +and ``honorific_tails`` raise ``ValueError`` naming the orphans, because +an orphan in each of those does real harm rather than nothing. The +other two are deliberately unchecked because an orphan there is inert: +``given_name_titles`` matches a title run as one space-joined string, or +by that run's last word, so a legitimate entry like ``"sir and dame"`` +is no single word in ``titles``; and a ``conjunctions_ambiguous`` entry +is only ever read for a word that is a conjunction, so +``remove(conjunctions={"e"})`` simply works and the stale marker entry +is never consulted. Turning title detection off ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -187,11 +192,13 @@ each way a source might punctuate it. Words that are also ordinary names ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Two fields — ``suffix_acronyms_ambiguous`` and ``particles_ambiguous`` -— mark entries from ``suffix_acronyms`` and ``particles`` that are also -plausible as ordinary name words on their own (an acronym suffix that -doubles as a nickname, a particle that doubles as a given name). They -don't add new vocabulary by themselves; they narrow how an existing +Three fields — ``suffix_acronyms_ambiguous``, ``particles_ambiguous`` +and ``conjunctions_ambiguous`` — mark entries from ``suffix_acronyms``, +``particles`` and ``conjunctions`` that are also plausible as ordinary +name words on their own (an acronym suffix that doubles as a nickname, +a particle that doubles as a given name, a connective letter that +doubles as an initial). They don't add new vocabulary by themselves; +they narrow how an existing entry is read when it appears alone. If you're not sure whether a word you're adding is one of these ambiguous cases, weigh how often it is a name against how often it is the credential. Marking it ambiguous is @@ -270,6 +277,49 @@ ambiguity is recorded and it becomes part of the surname — under any >>> Parser(lexicon=lex).parse("van Gogh").family 'van Gogh' +``conjunctions_ambiguous`` is the same idea for one-letter connectives. +A single letter written against the name's own case is an initial and +one written with it is the connective — but a name written wholly in +one case, all upper or all lower, says nothing either way, and this is +the set that decides it there. ``e`` is the one entry shipped: a bare +``E`` initial is common where an ``e`` between two surnames is rare, and +``y`` runs the other way, so ``y`` joins even written as a bare capital. + +.. doctest:: + + >>> parse("jose e maria santos").middle # 'e' reads as an initial + 'e maria' + >>> parse("JUAN GARCIA Y LOPEZ").family # 'y' joins + 'GARCIA Y LOPEZ' + >>> parse("Jose e Maria Santos").given # mixed case decides itself + 'Jose e Maria' + +A member also reports the fork, so a caller can see which reading was +taken: + +.. doctest:: + + >>> [a.kind for a in parse("jose e maria santos").ambiguities] + [] + +If your data is Portuguese, where ``e`` links surnames the way ``y`` +does in Spanish, take it out and the connective reading comes back: + +.. doctest:: + + >>> lex = Lexicon.default().remove(conjunctions_ambiguous={"e"}) + >>> Parser(lexicon=lex).parse("jose e maria santos").given + 'jose e maria' + +If your data is Dutch, where a bare single letter is an initial and +never a connective, add the other one instead: + +.. doctest:: + + >>> lex = Lexicon.default().add(conjunctions_ambiguous={"y"}) + >>> Parser(lexicon=lex).parse("juan garcia y lopez").middle + 'garcia y' + Bound given names ~~~~~~~~~~~~~~~~~~ diff --git a/docs/design/decisions.md b/docs/design/decisions.md index d9a585b1..f50ae00c 100644 --- a/docs/design/decisions.md +++ b/docs/design/decisions.md @@ -316,11 +316,35 @@ The reconciled v1-style banks (`tests/test_*.py`) carried eight `@pytest.mark.xf - 2026-08-21 #418 (PR #420) — the three-word carve-out counts the name that remains once the maiden name leaves. The count was taken while the marker and the maiden name were still pieces, so `juan y garcia nee jones` counted five, 'y' joined, and the family was empty once the clause left — given 'juan y garcia', family ''. Nine corpus names show it under an appended clause. Closed by order, not by an exclusion: the marker pass now runs before the count (decisions.md#M2, 2026-08-21), and the three-piece gate ahead of the count, which had the same exposure, is closed the same way. rules.md#P3 now says the count is of the name's own words. The question #397 and #411 left open — whether a word's membership in one vocabulary should suppress it from a count gating a rule keyed on another — is untouched: this was never a membership question, the words are simply not in the name. -Open: -[#383](https://github.com/derek73/python-nameparser/issues/383) -should the Latin-capital veto stand (bless / drop / extend). +- 2026-09-13 #383 + #479 — BLESSED for the mixed-case half, and DECIDED BY VOCABULARY for the one-case half, which is the half the question never reached. A single-letter connective joins only on positive evidence. Where a name is written in more than one case the writing is that evidence and nothing moves: a bare LATIN capital reads as an initial ("Jose E Maria Santos" → middle "E Maria"), which is #383 answered with "bless". The shape is still LATIN and the phrase "Latin-capital veto" does not retire with this change — `_INITIAL` is ASCII-only, so a Cyrillic capital is never initial-shaped, joins, and #267's reading above is untouched. An earlier draft of this work claimed the phrase retires; it was overstated, and what became evidence-based is the one-case half alone. + WHERE THE WRITING SAYS NOTHING. A name written wholly in ONE case — all upper and all lower alike — carries no case evidence about any letter in it. The reading there comes from a new marker subset `Lexicon.conjunctions_ambiguous` ⊂ `conjunctions`, default `{e}`: a member reads as an INITIAL and reports the new `AmbiguityKind.CONJUNCTION_OR_INITIAL`, a non-member JOINS even written as a bare capital and reports nothing. That is the third instance of the ambiguous-subset pattern, after `particles_ambiguous` and `suffix_acronyms_ambiguous`, so no mechanisms.md entry is owed. + ROW 1 OF #479 IS THE DEFECT, NOT ROW 4 (Derek, 2026-09-12). The one-case lowercase reading is the one that was wrong, not the one-case uppercase reading that looked wrong beside it. + WHY `e` AND NOT `y`. A bare E initial is common (Edward, Elizabeth) and an "e" between two surnames is rare outside couple listings; a bare Y initial is rare and "y" between two surnames is the commonest Hispanic compound and this library's oldest fixture ("Velasquez y Garcia"). The 2026-08-18 LOCALE CONTROL bullet under #3-0-reevaluations already said that which letters a tradition wants joined is per-word POLICY rather than membership; this subset is that policy made configurable, which is the smaller half of what that bullet asked for and ships without locale packs. `i` is not here because it is not conjunction vocabulary at all yet; it ships in the subset when #397 adds it, a bare I initial being as common as a bare E. + MEASURED (2026-09-13, this branch, over the deduped `tools/differential/corpus*.jsonl` glob — 1174 distinct names). SEVENTEEN names are written in one case and carry a cased single-letter connective: that is the WHOLE population this fork can reach, and ten of them move something against 2.3.0 (a role, `initials()`, or the report). Recompute the population by keeping every corpus name whose space-joined text equals its own `upper()` or `lower()` and which holds a one-character token that is in `conjunctions` after `_lexicon._normalize` and whose `upper()` and `lower()` differ; recompute the movers by parsing each of them here and under a released wheel and diffing the seven role fields, `initials()` and the ambiguity kinds. Of the EIGHT such names the corpora already held before this PR added its own examples, not one moves a role — the three-word carve-out or an existing initial reading covers every one of them. The two role moves are both new example names, and they break 1.4.0 parity in OPPOSITE directions: `jose e maria santos` read given "jose e maria" at every release from 1.4.0 through 2.3.0 and now reads given "jose", middle "e maria"; `JUAN GARCIA Y LOPEZ` read middle "GARCIA Y" and now reads family "GARCIA Y LOPEZ" (measured on the released 1.4.0 and 2.3.0 wheels from a throwaway environment, 2026-09-13 — this tree shadows the wheel otherwise). The ledgers at all five baselines classify them. + THE RULE IS PER TOKEN AT CLASSIFY TIME AND REACHES EVERY NAME LENGTH (Derek, 2026-09-13), the three-word shape included: `john e smith` reads middle "e" tagged as an initial, so `parse(...).initials()` gives "j. e. s." where 2.3.0 gave "j. s." and `capitalized()` gives "John E Smith" where 2.3.0 gave "John e Smith". P3's three-word carve-out is untouched — it already says a single letter in a short name is more likely an initial, and this is the same judgement extended to the letter's case — so no ROLE moves at three words and the visible change is in the two derived views. + BOTH QUESTIONS ARE ASKED OF THE NAME'S OWN WORDS — the case class AND the fork, not just the three-word count. Tokens standing before the first maiden-marker run head and not already carrying a role from a delimited clause are the name; the rest are not. Found by measurement rather than by design: appending " née Jones" made an all-caps name mixed-case and flipped the fork, failing the repo's invariant that a maiden clause changes nothing else, and a delimited `"Y"` was being conjunction-tagged (so R4 lowercased it) while a delimited `"E"` reported. ACCEPTED edge, and it is WIDER than an early draft of this entry said (measured 2026-09-13, in review): a maiden marker's run and every word AFTER it leave the own-words span the moment classify tags the marker, and they stay outside whether or not the marker pass later DECLINES the marker and leaves it a word. So a declined marker does two things, not one. It still does not count toward the case class, which is visible only when the declined marker word is the only differently-cased token (`JUAN Y GARCIA née` joins where `JUAN Y GARCIA née Jones` keeps "Y" a name word) — that much the early draft had. But it also leaves any connective standing after it to the MIXED-CASE rule rather than to the one-case fork, so a bare Latin capital there is an initial and its lowercase spelling joins. That is the one shape in which a name's OWN words read differently in its two one-case spellings: `JUAN NÉE JR Y LOPEZ` reads given "JUAN", middle "NÉE JR Y" with its "Y" tagged `initial`, while `juan née jr y lopez` reads given "juan", middle "née", family "jr y lopez" with its "y" tagged `conjunction`. SCOPE THAT PHRASE TO THE OWN WORDS, because a clause's words are not covered by it and a re-review caught the over-reach: a letter inside a delimited or maiden clause is read as it always was, which still means a different TAG in the two spellings, and case repair shows it — measured 2026-09-13, `JANE VAN DER BERG NÉE Y JONES` capitalizes to `Jane van der Berg (Y Jones)` where `jane van der berg née y jones` gives `(y Jones)`, and `JUAN "Y" GARCIA LOPEZ` keeps nickname `"Y"` where the lowercase spelling keeps `"y"`. The FIELDS agree in both pairs; what differs is the repaired case of a clause's word, which is that clause's rules doing their job rather than this one's. ACCEPTED rather than repaired, on two grounds: classify cannot know what group will decline, and a clause's words are read by the clause's own rules — which is the same doctrine the own-words span states, applied to the one shape where it bites. rules.md#P3's Accepted clause carries the same wording and the same example pair. + REPORT SCOPE IS SUBSET MEMBERS ONLY. Both readings are live only there; "y" in a one-case name is not in doubt, and reporting it would be noise on every lowercase Hispanic record. Matches how `particles_ambiguous` and `suffix_acronyms_ambiguous` report, and matches AGENTS.md's "a kind is worth adding only if a reader would hesitate too". + CASELESS LETTERS NEVER ENTER THE FORK. Arabic و has no case, so `token.upper() != token.lower()` is false and today's reading stands. A caseless-script input counts as "one case" under the helper, harmlessly, because the fork also requires a cased token. + NO SWITCH. The subset is the knob: remove "e" to restore the joining reading, add "y" for a Dutch-style "every single letter is an initial". That is also this shape's answer to #516's switch question. + ACCEPTED, AND RECORDED RATHER THAN FIXED: a locale pack cannot express "remove e". `Locale.lexicon` is unioned onto the base and never removes — its own field comment says "a pack never removes base vocabulary" — so an `nl` pack CAN add "y" while a `pt` caller has to write `Parser(lexicon=Lexicon.default().remove(conjunctions_ambiguous={"e"}))` by hand. The first (A) bullet added to #3-0-reevaluations on this date asks whether packs should be able to remove vocabulary at all. + ACCEPTED, AND DEFERRED TO A FOLLOW-UP ISSUE (Derek, 2026-09-13): the facade and the core now disagree about the same letter. `HumanName.initials()` does not follow the parse's tags — `_facade._process_initial` re-derives "is this the connective" from vocabulary plus initial shape on the part's raw text — so `HumanName("john e smith").initials()` stays "j. s." while `parse("john e smith").initials()` gives "j. e. s.", and `JUAN Y GARCIA` splits the other way, facade "J. Y. G." against core "J. G.". Both measured here 2026-09-13 and pinned by a contrastive test rather than left to prose. `_facade.py` is untouched in this PR; the fix has R4's shape — consult the PARSED token wherever the part maps to one and fall back to the vocabulary only where it does not — and it is a follow-up issue, not yet filed, so no number is cited here. `capitalize()` is not split: it follows the parse on both surfaces, so `HumanName("john e smith").capitalize()` gives "John E Smith" too. + TWO OTHER CAUSES SHARE THE `_initials` FIELD IN THE LEDGERS AND NEITHER IS THIS PR'S, recorded because a reader meeting them under these names will reach for this entry. The Arabic `محمد و علي` diffs on `_initials` at 1.4.0 only (`م. و. ع.` → `م. ع.`), measured byte-identical either side of the fork: it is a pre-existing #269 consequence — a recognized non-Latin connective contributes no initial — surfaced by the row entering the contract corpus, and `expected_since_1.4.0.toml` ledgers it as `feat(#269)`. `JUAN Y GARCIA` diffs on `_initials` at 2.0.0 through 2.2.0 for a DIFFERENT reason than it does at 2.3.0: at those three baselines `fix(#462)` already admits the name (the facade moved `J. G.` → `J. Y. G.` there), and this PR's core move `J. Y. G.` → `J. G.` gets its own rule at 2.3.0 alone. Two causes, one field, documented in a dated paragraph on `fix(#462)` in those three ledgers rather than as a competing rule. + Out of scope, each its own issue: #492 (whether a cased suffix token counts as case evidence — `is_one_case` is written so R5 and #492 can share it later, but render does not import it here); #478 (hyphenated connective repair — its spaced-form claim now depends on "y" staying OUT of the subset); #461 (R3's clause); #289 and #516 (the same case-class fact read at the suffix and post-comma slots); the render-side conjunction fallback for spliced raw text, which stays vocabulary-keyed as rules.md#R4 states. + +- 2026-09-13 #383/#479 — `conjunctions_ambiguous` is deliberately NOT registered in `_SUBSET_FIELDS`, so an orphan marker entry raises nothing. An orphan there is INERT rather than harmful: the classify fork tests membership in `conjunctions` before it reads the subset, and the emitter tests it again, so a marker entry whose base word is gone is never consulted by either. AGENTS.md's invariants rule guards harm, not no-ops, and the precedent is this file's #given-name-titles Declined entry, where two attempts at a check each cost a working configuration to forbid a condition that costs nothing. What holds the SHIPPED constant to the subset relation is an import-time assert in `nameparser/config/conjunctions.py` (which also holds it to cased single letters, the only entries the fork can read), and the v1 shim's intersection, which is a provable no-op. So `remove(conjunctions={"e"})` simply works, leaving a stale marker entry behind that does nothing. Pinned by `test_removing_a_conjunction_leaves_its_ambiguous_marker_alone` in tests/v2/test_lexicon.py and by the orphan behavioral pin in tests/v2/pipeline/test_classify.py, both of which cite this entry. + - Provenance: the single-letter-connective guard is v1's fix for Google Code issue 11 ("john e smith", 2013, commit 33676c9) — the "#11" citations that circulated pointed at a GitHub accident, not the real source. Recorded so the archaeology stays done. +Excluded (Lexicon.conjunctions_ambiguous, the marked half of nameparser/config/conjunctions.py — an entry here reads as an INITIAL in a name written wholly in one case): + +- `y` — the commonest Hispanic double-surname link and this library's oldest fixture ("Velasquez y Garcia"); marking it would read the surname link of every lowercase Hispanic record as a middle initial. A caller who wants the Dutch reading adds it (2026-09-13, #383/#479). +- Cyrillic `и`, `і`, `й` — #267 blessed their joining and this change narrows nothing about it; marking one would break the reading #267 adjudicated, in the one-case spellings where nothing else decides (2026-09-13, #383/#479). +- `و` and any other caseless letter — inert rather than harmful, and excluded so nobody adds one believing it does something: the fork requires a token whose `upper()` and `lower()` differ, so a caseless member is never consulted (2026-09-13). +- Multi-letter entries (`and`, `та`, `και`) and `&` — the fork tests a single CHARACTER, so an entry longer than one letter is silently inert. `nameparser/config/conjunctions.py` asserts against it at import (2026-09-13). + +Open (Lexicon.conjunctions — contested membership; the issue is canonical): +[#397](https://github.com/derek73/python-nameparser/issues/397) whether Catalan `i` belongs in the conjunctions at all. Its membership in `conjunctions_ambiguous` is NOT the open half — that is decided, and it ships in the subset in the same change that adds it to the base set, a bare I initial being as common as a bare E. + ### given-name-titles — deliberately unvalidated Declined (rc1 arc; the full argument is AGENTS.md's gotcha): @@ -1186,6 +1210,9 @@ R3's earlier history is under `decisions.md#R2`, which this entry does not repea - 2026-08-29 — WHAT THE OVERRIDE DOES AND DOES NOT PROMISE, from Derek's framing of R5 and then measured, because the framing implies a property that is ALMOST true and the gap is the useful part. The framing first, and it supersedes the correctness talk elsewhere in this entry: mixed case is the writer making an explicit choice, and repair defers to that choice instead of judging it. Nothing is being called correct or incorrect — a name kept is a name whose writer said something about it, and a name repaired is one whose writer did not. The property that seems to follow is that asking for repair REGARDLESS should ignore the given casing entirely, so one name repairs to one string however it was written. MEASURED over the 1094 corpus names and it does NOT hold. On the v2 core — `parse(n).capitalized(force=True)` against `parse(n.upper()).capitalized()`, rendering all seven roles — the two differ for 63 names, and the lowercase direction for 18. Naming the surface matters here, and BOTH counts move with the surface, which an earlier draft of this bullet got half right: through `HumanName` and `str()`, which is what tests/test_capitalization.py uses, the counts are 62 and 16 — and the two directions are dropped for DIFFERENT reasons, which an earlier draft of this bullet ran together under the first. UPPER: the facade's default render spec omits the maiden name, so `str()` cannot see `Jane van der Berg née y Jones` (the whole of the upper difference, and it is a conjunction inside the maiden name — `maiden` is `y Jones` forced against `Y Jones` uppercased and every other role is byte-identical). LOWER: the two the facade drops are `John van der J. V` and `abdul V Smith`, and the maiden field is EMPTY in both directions for both, so the omitted role explains neither. What hides them is that `str()` CONCATENATES adjacent roles, so a token that crosses a role BOUNDARY and moves nothing else is invisible in the joined string: `John van der J. V` is family `van der J. V` forced against family `van der J.` plus suffix `V` lowercased, and `abdul V Smith` is given `Abdul V` forced against given `Abdul` plus middle `V` lowercased — same seven roles rendered, same string joined. Re-derived 2026-08-29 on this branch, and named per name so the next reader can check the arithmetic: core 63/18, facade 62/16. Two things about those numbers are the opposite of what one would guess. UPPERCASE IS THE WORSE DIRECTION, not the clean one. And the misses are not merely the pipeline's case-sensitivity leaking in: of the 63, only 25 move a role at all, and the other 38 parse byte-identically and diverge inside the repair (25 and 37 through the facade, the missing name being one of the byte-identical ones). The mechanism is v1's initial carve-out — a conjunction is not lowercased where it is written initial-shaped, and initial-shaped means one CAPITAL letter — so uppercasing turns every one-letter conjunction into an initial (`Velasquez y Garcia, Dr. Juan Q.` forced keeps `y`; the same name uppercased then repaired gives `Y`, with a byte-identical partition either side -- the comma form is the one to cite here, the space-written `Dr. Juan Q. Velasquez y Garcia` being a member of the 25 whose roles DO move), and lowercasing turns a middle initial `E` into the Italian conjunction. 1.4.0 does the same (`JUAN Y GARCIA` capitalizes to `Juan Y Garcia`), so this is inherited, and it is recorded here rather than fixed here. Recompute both directions by running the two forms over the corpus files deduped and diffing. - 2026-08-29 — and therefore NOT stated in rules.md, which is a deliberate choice rather than an oversight. The document's examples are keyed on input STRINGS, so any statement of the property invites exactly the test that falsifies it — re-case the input, expect the same output — and the counterexamples are already in the corpora. The property is true of the repair given a parse, and rules.md speaks input-to-output; a rule stating it would be over-broad in the one direction a reader would check. What R5's statement says is enough for the promise that IS kept: a mixed-case name is kept unless repair was asked for anyway. That clause was REWORDED for this, and the reword is the whole point rather than a tidy-up. It read `unless repair regardless of how the name is cased was asked for`, which carries two readings -- the intended one, that the request overrides the keeping, and a second one, that the repair disregards the input's casing, which is this property in nearly this bullet's own words. A reader taking the second reading would run the re-casing test predicted above, land on `Velasquez y Garcia, Dr. Juan Q.` (in the corpus today), and conclude the RULE is wrong when only the phrasing was. Nine words, and they asserted the thing the paragraph exists to deny. The property is pinned in tests/test_capitalization.py instead, over names carrying no single-letter word whose class case decides, with `juan y garcia` beside it as the recorded exception. R5's example block gains `"SHIRLEY MACLAINE" → capitalized="Shirley MacLaine"` from this work, and it earns its place on its own ground rather than as half of a convergence pair: it is the only row in the block that fails when the gate is narrowed to lowercase-only, every other row passing that mutation. Measured three ways — gate deleted (passes, so it does not witness the gate's existence), gate narrowed to accept only all-lowercase (FAILS, and alone in the block), Mac/Mc convention deleted (fails, with the other two rows). Until it was added, R5 stated that repair acts on a name written entirely in one case and witnessed only the lowercase half of it. That lowercase half is still `"juan mcdonald"`, which is byte for byte an R4 row as well, and the duplication is deliberate rather than an editing slip: the two rules make different claims about the same line — R4 that the repair honors the Mac/Mc convention, R5 that an all-lowercase name is acted on at all — and dropping it from R5 would leave the gate's lowercase half unwitnessed inside the rule that states the gate. Five other rows already sit under two rules apiece for the same reason (P5/P6 twice, P5/O5, N3/M4, W1/W3). - 2026-08-29 — DEBT this extraction leaves, named so the next commit inherits an obligation rather than a rediscovery. Pulling the gate out into R5 leaves R4 carrying ONE falsehood and ONE ambiguity — different defects wanting different repairs, and `interacts: R5` carries neither, the field being advisory. FALSE: R4 promises repair "vocabulary exceptions (McDonald) included", but `str(parse('Juan Mcdonald').capitalized())` is `'Juan Mcdonald'` — the gate refuses before any vocabulary is consulted, and only `str(parse('Juan Mcdonald').capitalized(force=True))`, `'Juan McDonald'`, reaches the exception. R4 needs its promise scoped to names the gate admits. AMBIGUOUS, not false: R4's "an already-correct name comes back unchanged" means correct by the repair's own conventions, i.e. idempotence, and under that meaning it is true; a reader hears correct as the bearer writes it, and under THAT meaning `str(parse('bell hooks').capitalized())` — `'Bell Hooks'` — looks like a counterexample. It is not one, because `bell hooks` is not already-correct in R4's sense. What R4 owes is a disambiguation of "correct", NOT a narrowing to spare deliberately single-cased names: that would be new behavior, and R5's own rationale declines it on the ground that single case leaves the repair nothing to read. Also for that commit, and inert as things stand: R4's boundary row `"Juan McDonald" → capitalized="Juan McDonald"` passes with R5's gate deleted, exactly like the R5 row that was withdrawn above; rewriting it to `capitalized_forced=` makes it discriminate for R4's own subject but still witnesses nothing about the already-correct question. This commit adds R5 and touches R4 only on its pointer line, leaving both defects as found rather than half-fixed by a commit whose subject is something else. +- 2026-09-13 #383/#479 — AMENDMENT to the 2026-08-29 override bullet above: the MECHANISM it names is gone, and with it the direction it called surprising. That bullet explained the re-casing gap by v1's initial carve-out — "uppercasing turns every one-letter conjunction into an initial" — and cited `Velasquez y Garcia, Dr. Juan Q.` forced (keeping "y") against the same name uppercased then repaired (giving "Y"). Measured on this branch today, all three spellings agree: forced, uppercased-then-repaired and lowercased-then-repaired each give `Dr. Juan Q. Velasquez y Garcia`, and the space-written `Dr. Juan Q. Velasquez y Garcia` does the same. A one-case name no longer reads a bare capital connective as an initial on shape; the vocabulary decides, and "y" is outside the marked subset (decisions.md#P3, 2026-09-13). Nothing about R5's gate changed — only what repair finds on the other side of it. + THE DIRECTION REVERSED, which is the part worth carrying: uppercase was the worse direction and is now the better one. Recipe, one comparator, both sides — over the deduped `tools/differential/corpus*.jsonl` glob, compare `parse(n).capitalized(force=True)` against `parse(n.upper()).capitalized()` and against `parse(n.lower()).capitalized()`, rendering all seven role fields, and count the names that differ in each direction; run it once on this tree and once against a released wheel installed in a throwaway environment (the tree shadows the wheel otherwise). Measured 2026-09-13 over 1174 names: the 2.3.0 wheel gives 69 upper and 23 lower, this tree gives 11 upper and 18 lower. On the facade surface — `HumanName(n).capitalize(force=True)` read back with `str()`, against the same call on the re-cased spellings — 2.3.0 gives 68 and 20, this tree 10 and 15; the surface still matters for the reasons the 2026-08-29 bullet names (the default render spec omits the maiden name, and `str()` concatenates adjacent roles). The uppercase count collapsed; the lowercase one did not, its remaining members being what the carve-out never explained. The 63/18 and 62/16 figures in the bullet above are a dated snapshot over a 1094-name corpus and are not reproducible today in either direction — do not treat them as this recipe's before-column. + rules.md#R5 needs no matching amendment: it never carried the claim. The 2026-08-29 bullet that follows the override bullet above says so in its own words ("and therefore NOT stated in rules.md"), and R5's Rationale, statement and example lines were re-read on this branch to confirm it. What DID move in the suite is the mirrored pin in tests/test_capitalization.py, where `JUAN Y GARCIA` had been pinned as inherited 1.4.0 behavior ("Juan Y Garcia") and now agrees with its lowercase spelling ("Juan y Garcia"), with a mixed-case control beside it. ### parse-cost — what a parse is allowed to cost @@ -1271,3 +1298,5 @@ Promoted 2026-08-15 from session memory (Derek's 2026-07-30 ask; promotion appro - (B) The FAMILY_COMMA doctrine (rule W3): inherited from v1's lastname-comma but correct on its own terms — an explicit comma is stronger evidence than script. - (A) 2026-08-17 — PARTICLE VOCABULARY AS LOCALE PACKS. Raised while reversing the order-independence decision above: every argument in the leading-particle thread is a language judgement wearing a position heuristic. `de` and `do` lead surnames in Vietnamese, `von` is German, `dos` Portuguese, `das` both Portuguese and a borne Bengali surname — and the per-word comments in nameparser/config/particles.py cite exactly these traditions as their justification. Locale packs already exist (ru, tr_az, zh, ja) and already carry vocabulary and rotations, so particles are the obvious next tenant: a caller who knows the tradition could opt into a tuned set instead of the parser splitting the difference globally. Not 2.x work — it needs the packs to carry vocabulary OVERRIDES rather than additions, and it would give C-i's positional qualifier a per-locale answer rather than one global one. - (A) 2026-08-18 — LOCALE CONTROL OF P3's SINGLE-LETTER CARVE-OUT, and the sharper requirement it puts on the entry above: a pack would have to carry per-word POLICY, not just membership. Which single letters a tradition wants joined differs by language — Spanish writes "y" and would want "e" read as an initial, Portuguese wants the reverse, Dutch would want every single letter read as an initial. One global rule serves all three today: a single ALPHABETIC character in a name of fewer than four pieces stays a name word, and a bare Latin capital never joins (#383). It is v1's Google Code issue 11 fix ("john e smith") generalized to a vocabulary v1 did not have — the set now holds six single-letter conjunctions (y, e, и, і, й, و), and the carve-out reaches all of them because it counts characters, while the capital veto reaches only the Latin ones because it tests a Latin shape. That asymmetry is the visible seam and it is why #383 is open. A pack that could only ADD or REPLACE vocabulary could not express any of it; the join threshold and the initial veto would have to be per-word, per-pack values. Measured while documenting P3: "&" is single-character but not a letter, so it joins at any length ("Juan & Garcia" is one part while "Juan y Garcia" is three). +- (A) 2026-09-13 — SHOULD A LOCALE PACK BE ABLE TO REMOVE BASE VOCABULARY? `Locale.lexicon` is UNIONED onto the base and never removes — the field's own comment says so — which makes `conjunctions_ambiguous` (decisions.md#P3, 2026-09-13) expressible by a pack in ONE DIRECTION ONLY: an `nl` pack can add "y", and a `pt` pack cannot remove "e", so a Portuguese caller has to write `Parser(lexicon=Lexicon.default().remove(conjunctions_ambiguous={"e"}))` by hand. Accepted for 2.x and parked here rather than filed, because 3.0 is when a wider set of locale packs gets designed and this is part of that work (Derek, 2026-09-13). It is the 2026-08-17 PARTICLE VOCABULARY AS LOCALE PACKS bullet's "overrides rather than additions" requirement meeting a second tenant, which is what makes it a shape question rather than one pack's inconvenience. +- (A) 2026-09-13 — THE DIFFERENTIAL HARNESS CANNOT SEE A LOCALE PACK'S EFFECT AT ALL. `compare.py` builds one `Parser` per name ORDER and nothing else, and a `Case.locale` row projects into the corpora as a bare name string — no corpus row carries a locale key, verified over every `tools/differential/corpus*.jsonl` file on 2026-09-13 — so every corpus name is parsed under the DEFAULT `Lexicon` on both sides. A pack's effect on a name can therefore never diff, at any baseline. That is fine while packs only ADD vocabulary the default already carries most of, and stops being fine the moment the bullet above is answered yes. The shape of the fix is a `locale` carried per corpus ENTRY the way `shape` already carries an order, resolved at the baselines that ship the pack and skipped below them. Parked with the bullet above rather than filed, for the same reason: the two are one piece of work (Derek, 2026-09-13). diff --git a/docs/design/rules.md b/docs/design/rules.md index 6d4b2160..f4bb0230 100644 --- a/docs/design/rules.md +++ b/docs/design/rules.md @@ -553,18 +553,34 @@ P3. Rationale: connective words ("y", "of the") bind name words into initial, the shape the veto always tested, which is why #267's Cyrillic reading is untouched — and answered with evidence for the one-case half, where the vocabulary decides. - A maiden marker the marker pass declines (left as a word, M2) is - nonetheless excluded from the case class, which shows only when - the marker word is the only differently-cased token — "JUAN Y - GARCIA née" joins where "JUAN Y GARCIA née Jones" keeps "Y" a - name word — accepted rather than repaired, since classify cannot - know what group will decline. + A maiden marker's run and every word after it are outside the + name's own words from the moment classify tags the marker, and + they stay outside whether or not the marker pass later declines + the marker and leaves it a word (M2). A declined marker therefore + does two things at once. It still does not count toward the case + class, which shows when it is the only differently-cased token, + so "JUAN Y GARCIA née" joins where "JUAN Y GARCIA née Jones" + keeps "Y" a name word. And it leaves any connective standing + after it to the mixed-case rule rather than to the one-case fork, + so a bare Latin capital there reads as an initial while its + lowercase spelling joins — the one shape in which the name's OWN + words read differently in its two one-case spellings. So "JUAN + NÉE JR Y LOPEZ" reads middle "NÉE JR Y" with its "Y" an initial, + while "juan née jr y lopez" reads family "jr y lopez" with its + own "y" joined. A clause's words are read as they always were, + and that is not an exception to this: such a letter still takes + a different tag in the two spellings, so its case repair can + differ between them, which is a repair difference and not a + reading of the name's own words. + Accepted rather than repaired: classify cannot know what group + will decline, and a clause's words are read by the clause's own + rules. "Хосе И Мария Сантос" → given="Хосе И Мария" H1 is the counting rule that shows the one-word clause today: a title plus the join reads the whole join as the family, where the same two words unjoined are two name words and H1 does not fire. - P1's leading run becomes the second once #395 lands — its run - must take the "Vega y Santos" join whole or stop before it. + P1's leading run is the second (#395, landed): its run takes + the "Vega y Santos" join whole or stops before it. history: decisions.md#P3 · interacts: H1, P1, M2, R3, R4 · implemented: nameparser/_pipeline/_classify.py, nameparser/_pipeline/_group.py, nameparser/_pipeline/_post_rules.py P4. Rationale: a particle links forward from inside a name; at the @@ -1721,10 +1737,13 @@ R3. Rationale: initials abbreviate the person's name words; titles, line because every line here names an input string, and this shape needs a field edited after the parse. Accepted: the unsettled given-group answer above is neither rare - nor hypothetical — 25 of the corpus names carry a conjunction - among the given names, every one of them reachable from the - default vocabulary, and it has initialed since 1.4.0. It carries - no marked deviation, for the reason that mechanism exists: a + nor hypothetical — 26 of the corpus names carry a conjunction + among the given names (measured 2026-09-13; recompute by parsing + the deduped corpus*.jsonl glob and keeping every name with a + GIVEN-role token tagged "conjunction"), every one of them + reachable from the default vocabulary, and it has initialed since + 1.4.0. It carries no marked deviation, for the reason that + mechanism exists: a marker states the INTENDED value, and one name, "John and Jane Smith", has four candidates. Today gives "J. a. J. S."; the carve-out read as written gives "J. J. S."; P3's one-name-word diff --git a/docs/release_log.rst b/docs/release_log.rst index 63f70bc4..8a2050f9 100644 --- a/docs/release_log.rst +++ b/docs/release_log.rst @@ -4,6 +4,16 @@ Release Log nameparser 2.4 is under development. + **Behavior Changes** + + - **Fix a one-letter connective joining a name that gives no sign it is a connective.** ``HumanName("jose e maria santos")`` gives first ``jose``, middle ``e maria``, last ``santos``, where 1.4.0 through 2.3.0 gave first ``jose e maria``; and ``JUAN GARCIA Y LOPEZ`` gives last ``GARCIA Y LOPEZ``, where every release since 1.4.0 read the bare capital as an initial and gave middle ``GARCIA Y``. A single letter is an initial where the writing says so -- a bare Latin capital in a name that is not written wholly in one case -- and a name written wholly in one case says nothing either way, so the reading comes from the vocabulary there: ``e`` reads as an initial and ``y`` joins. Mixed-case input is untouched in both directions: ``Jose e Maria Santos`` still gives first ``Jose e Maria`` and ``Jose E Maria Santos`` still gives middle ``E Maria``. Short names move in the derived views rather than the fields, P3's three-word carve-out being unchanged: ``parse("john e smith").initials()`` is ``j. e. s.`` where 2.3.0 gave ``j. s.``, and ``HumanName("john e smith").capitalize()`` gives ``John E Smith`` where 2.3.0 gave ``John e Smith``; ``JUAN Y GARCIA`` moves the same way in reverse, ``parse(...).initials()`` giving ``J. G.`` where 2.3.0 gave ``J. Y. G.`` and ``capitalize()`` giving ``Juan y Garcia``. On those two short names ``HumanName.initials()`` is unchanged -- the facade reads the letter by vocabulary and written shape rather than by the parse, and a follow-up issue carries the split -- but where a ROLE moves the facade's initials follow the fields like any other view: ``HumanName("jose e maria santos").initials()`` is ``j. m. s.`` where 2.3.0 gave ``j. e. m. s.``. Seventeen names in the differential corpora are written in one case and carry a cased single-letter connective, and ten of them move something against 2.3.0. The Cyrillic reading is unchanged (``Хосе И Мария Сантос`` still gives first ``Хосе И Мария``), and Arabic ``و`` never enters the rule, having no case to be written against. A ``Lexicon`` knob decides which letters are marked, so the reading is configurable rather than fixed. See the ``P3`` entry of ``docs/design/decisions.md`` (closes #383, closes #479) + + **Additions** + + - **Add Lexicon.conjunctions_ambiguous, the one-letter connectives that read as initials.** A subset of ``conjunctions`` holding ``e`` by default; it is the knob for the change above rather than a switch. Portuguese data, where ``e`` links surnames the way ``y`` does in Spanish, takes it out: ``Lexicon.default().remove(conjunctions_ambiguous={"e"})`` restores the joining reading. Dutch data, where a bare single letter is an initial and never a connective, adds the other one: ``Lexicon.default().add(conjunctions_ambiguous={"y"})``. A v1 ``Constants`` has no manager of its own for it -- deleting the word from ``conjunctions`` is what turns the marking off, the same rule the glued-honorific tails follow. See ``docs/customize.rst`` (#383, #479) + + - **Add AmbiguityKind.CONJUNCTION_OR_INITIAL, reported when a one-letter connective in a name written wholly in one case is read as an initial:** ``parse("jose e maria santos").ambiguities`` and ``parse("JOSE E MARIA SANTOS").ambiguities`` both name it, and ``detail`` names the letter. That is the call the behavior change above had to make. A letter outside the marked set reports nothing, its reading not being in doubt, so ``JUAN GARCIA Y LOPEZ`` is silent; so is every mixed-case name, where the writing decided it. See the ``P3`` entry of ``docs/design/decisions.md`` (#383, #479) + * 2.3.0 - September 12, 2026 nameparser 2.3 is parsing fixes and new honorific vocabulary; diff --git a/nameparser/config/conjunctions.py b/nameparser/config/conjunctions.py index 1b5a03c6..4164b68a 100644 --- a/nameparser/config/conjunctions.py +++ b/nameparser/config/conjunctions.py @@ -64,10 +64,14 @@ assert_normalized("CONJUNCTIONS", CONJUNCTIONS) # Guard the invariant the docstring promises, so a future edit that -# breaks it fails at import time (same rationale as suffixes.py). Note -# `assert` is stripped under `python -O`; Lexicon re-checks the -# relationship at construction, which is what protects a caller's own -# vocabulary. +# breaks it fails at import time (same rationale as suffixes.py). +# This holds the SHIPPED constant and nothing else, and two things +# follow. `assert` is stripped under `python -O`, so under -O even +# that much is gone. And unlike the other marker subsets, the pair is +# deliberately absent from Lexicon's subset checks, so a CALLER'S +# orphan is never rejected -- it is inert instead, the classify fork +# and its emitter both requiring the base entry before they read the +# marker (see decisions.md#P3, the 2026-09-13 entries). assert CONJUNCTIONS_AMBIGUOUS <= CONJUNCTIONS, \ "CONJUNCTIONS_AMBIGUOUS must stay a subset of CONJUNCTIONS" assert all(len(w) == 1 and w.upper() != w.lower() From f3404f68fa61854f2413f36654d7f3162c9b4d5a Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Sun, 13 Sep 2026 17:32:05 -0700 Subject: [PATCH 4/6] fix(#383/#479): a maiden marker word inside a clause starts no clause _tag_marker_runs walked every token including a maiden-marker word that arrives already ROLED (parenthesised/quoted clause content extract pre-assigned) -- classify's clause_at loop then truncated the name's "own words" at that index, excluding own text AFTER the clause from the case class. "JUAN (NEE JONES) GARCIA Y LOPEZ" misread the trailing "GARCIA Y LOPEZ" as mixed case and gave 'Y' an initial instead of joining the family; "jose e maria \"Nee\" Santos" lost the capitalized "Santos" from the case class and wrongly reported 'e'. Fixed by skipping a marker match whose token already carries a role -- it is the clause's own word, not a bare marker opening a new one. Review items applied from the #527 PR review: - test coverage: a title counts toward the case class unlike a clause ("Dr. JUAN GARCIA Y LOPEZ" vs "DR. JUAN GARCIA Y LOPEZ"); the per-token emitter on "jose e maria e santos"; comma structure does not change classify's per-token reading; the caseless-connective test now also marks its probe ambiguous, the only configuration where the casedness gate decides anything; y-side initials() pins in test_render.py; deleted a StrEnum-equals-its-own-value test that pinned nothing test_ledger_guards.py's role-rule probes didn't already cover. - comment accuracy: the conjunctions_ambiguous intersection in _config_shim.py is a provable no-op, not something Lexicon checks; the Cyrillic connective's join lands in GIVEN, not FAMILY; the frame-cost rationale belongs to is_one_case's Sequence parameter, not to why `texts` is a list; the "initial" tag is a word READ as an initial (rules.md#M4, #_types.py, docs/modules.rst, _state.py all reworded to match); a few stray "when #397"/"the policy"/dropped commit-label phrasings. The five differential gates (1.4.0/2.0.0/2.1.0/2.2.0/2.3.0) report the same intentional/unexplained counts as before (418/349/263/126/10 intentional, 0 unexplained at every baseline) -- no name moved. Co-Authored-By: Claude Fable 5.1 --- docs/design/rules.md | 2 +- docs/modules.rst | 8 ++- nameparser/_config_shim.py | 12 ++-- nameparser/_pipeline/_classify.py | 33 +++++++--- nameparser/_pipeline/_post_rules.py | 2 +- nameparser/_pipeline/_state.py | 2 +- nameparser/_types.py | 23 ++++--- nameparser/config/conjunctions.py | 4 +- tests/test_capitalization.py | 5 +- tests/v2/cases.py | 10 +-- tests/v2/pipeline/test_classify.py | 95 ++++++++++++++++++++++++++++- tests/v2/test_contracts.py | 5 -- tests/v2/test_ledger_guards.py | 8 ++- tests/v2/test_render.py | 13 ++++ 14 files changed, 177 insertions(+), 45 deletions(-) diff --git a/docs/design/rules.md b/docs/design/rules.md index f4bb0230..7fcfea7a 100644 --- a/docs/design/rules.md +++ b/docs/design/rules.md @@ -1149,7 +1149,7 @@ M4. Rationale: a maiden name is a FORMER family name, and a former decided, so it cannot overrule what a word already IS (mechanisms.md#TWO-LAYER-ASSIGN). A word the vocabulary has claimed as a given name keeps that reading, and so does a word - written as an initial, which is nobody's family name. A name + read as an initial, which is nobody's family name. A name carrying a TITLE is H1's rather than this rule's, H1's given-name-title carve-out included, which keeps the word a given name. A nickname holds nothing off: where N3 has already diff --git a/docs/modules.rst b/docs/modules.rst index a42935ce..55febcab 100644 --- a/docs/modules.rst +++ b/docs/modules.rst @@ -49,9 +49,11 @@ Results stable API: ``particle`` (a word from the particle vocabulary, "de"/"van", wherever it lands — combine with ``Role.FAMILY`` for actual family particles), - ``conjunction`` (a joining word, "and"/"y"), ``initial`` (an - initial-shaped word in a script that HAS initials — "J." or "А.", - never "씨."), and ``joined`` (a continuation of the token before it + ``conjunction`` (a joining word, "and"/"y"), ``initial`` (a word + READ as an initial — initial-shaped in a script that HAS initials, + "J." or "А.", never "씨.", or a marked single-letter connective in + a name written in one case, rules.md#P3), and ``joined`` (a + continuation of the token before it — within one merged piece the tag is role-blind and every view joins the pair with a space, so it renders "Ph. D." as one credential and ``Smith, Ph. D. Smith`` gives ``first_list`` diff --git a/nameparser/_config_shim.py b/nameparser/_config_shim.py index 1f3ac6e6..601dd5d0 100644 --- a/nameparser/_config_shim.py +++ b/nameparser/_config_shim.py @@ -1067,11 +1067,13 @@ def _build_snapshot(self) -> tuple[Lexicon, Policy, _RenderDefaults]: conjunctions=conjunctions, # no v1 manager of its own: the ambiguous-connective # subset is 2.4 behavior (#383/#479), so it rides in the - # snapshot only. Intersect with the conjunction set, the - # same rule honorific_tails gets against suffix_words below: - # Lexicon enforces the subset, and v1 semantics are that - # deleting the base word turns the behavior off -- a - # lingering marker simply stops mattering. + # snapshot only. Lexicon does NOT check this pair -- unlike + # honorific_tails against suffix_words below -- so the + # intersection is a provable no-op, kept only for + # `_snapshot() == Lexicon.default()` legibility; the v1 + # knob (deleting the conjunction) turns the marking off + # through the fork's own base-vocabulary test rather than + # through this intersection. conjunctions_ambiguous=CONJUNCTIONS_AMBIGUOUS & conjunctions, bound_given_names=bound, # v1 Constants has no manager for these (#274 is 2.0 diff --git a/nameparser/_pipeline/_classify.py b/nameparser/_pipeline/_classify.py index 9a2e6772..9181fc33 100644 --- a/nameparser/_pipeline/_classify.py +++ b/nameparser/_pipeline/_classify.py @@ -63,8 +63,8 @@ def _tags_for(token: WorkToken, n: str, state: ParseState, _tag_marker_runs'; only the writing happens here, so the two tokens of a phrase are built once rather than replaced twice. - `one_case_own` is true when the whole name is written in one case - AND this token is one of the name's own words -- a maiden clause + `one_case_own` is true when the name's OWN words are written in one + case AND this token is one of the name's own words -- a maiden clause and any delimited (nickname) content are not, so the fork never reads them either (rules.md#P3): a clause's words are not the name's own words, and appending one must not change how THIS token @@ -110,7 +110,7 @@ def _tags_for(token: WorkToken, n: str, state: ParseState, # a bare capital y joins here, where mixed case vetoes it tags.add("conjunction") else: - # today's rule, verbatim. v1's is_conjunction excludes + # the mixed-case rule, unchanged. v1's is_conjunction excludes # initials: 'e.' in 'john e. smith' is a middle initial, not # the Spanish conjunction 'e' if n in lex.conjunctions and not is_initial(token.text): @@ -237,9 +237,10 @@ def classify(state: ParseState) -> ParseState: # One fold per token, shared by the marker pass and the vocabulary # tags -- the shape suffix_as_written already asks for ("n is # _normalize(text), passed in so callers normalize once"). `texts` - # is built once and reused for `folded`: a generator handed to - # is_one_case instead costs one profiler frame per RESUME, i.e. per - # token, on every parse (#475's frame-count band caught this). + # is a LIST, not a generator, because `own` below indexes it by + # position (`texts[i]`); is_one_case's own `Sequence` parameter is + # where the frame-cost argument for passing a built sequence lives + # (_vocab.py, #475). texts = [t.text for t in state.tokens] folded = [_normalize(x) for x in texts] marker_tags = _tag_marker_runs(state, folded) @@ -257,7 +258,15 @@ def classify(state: ParseState) -> ParseState: # cheaper path given the order this dict happens to arrive in. clause_at = len(texts) for i, tag in marker_tags.items(): - if tag == "vocab:maiden-marker": + # A marker word already carrying a role arrived pre-set by + # extract (WorkToken.role's docstring) -- it is the CLAUSE's + # word, not a bare one opening a new clause, so it must not + # move clause_at: a delimited/maiden clause's own marker + # content is excluded from "own" by its role already, and + # letting it also set clause_at truncates the OWN words that + # follow the clause ("JUAN (NEE JONES) GARCIA Y LOPEZ"'s + # trailing "GARCIA Y LOPEZ" is such own text). + if tag == "vocab:maiden-marker" and state.tokens[i].role is None: clause_at = i break # ONE fact per parse, taken over the name's OWN words (rules.md#P3): @@ -305,9 +314,13 @@ def classify(state: ParseState) -> ParseState: # # Of the two clauses beside it, one is load-bearing and one is # not. `conjunctions_ambiguous` is IMPLIED by the others for a - # fresh parse: "initial" plus len 1 forces an ASCII capital, - # hence cased, hence the fork's branch was taken. It is kept - # only because `_tags_for` starts from `set(token.tags)`, so + # fresh parse -- the contrapositive of what it looks like: + # `is_initial` matches an ASCII capital only, so ONLY the + # fork's branch can tag a bare LOWERCASE letter "initial" + # ("jose e maria santos" tags a lowercase e), and it does so + # only for a conjunctions_ambiguous member. So for a letter of + # EITHER case, "initial" plus len 1 implies membership. It is + # kept only because `_tags_for` starts from `set(token.tags)`, so # this clause is what keeps the emitter honest if a runner ever # hands classify tokens it did not build; no such path exists # today. `conjunctions` is the clause doing real work: it keeps diff --git a/nameparser/_pipeline/_post_rules.py b/nameparser/_pipeline/_post_rules.py index 7c9dc1ec..f391ebbf 100644 --- a/nameparser/_pipeline/_post_rules.py +++ b/nameparser/_pipeline/_post_rules.py @@ -438,7 +438,7 @@ def post_rules(state: ParseState) -> ParseState: # no corpus name and is not this change's to make # (decisions.md#M4). # rules.md#M4: "a word the vocabulary has claimed as a given name - # keeps that reading, and so does a word written as an initial" + # keeps that reading, and so does a word read as an initial" # -- read off the tags classify already recorded rather than a # predicate of this rule's own, because this rule changes what # POSITION decided and must not reach what a word IS diff --git a/nameparser/_pipeline/_state.py b/nameparser/_pipeline/_state.py index 98054a95..5aba83d4 100644 --- a/nameparser/_pipeline/_state.py +++ b/nameparser/_pipeline/_state.py @@ -57,7 +57,7 @@ class WorkToken: #: M4's two carve-outs, as the tags classify recorded them: a bound #: given-name word is vocabulary claiming the word as a given name, -#: and `initial` is the shape claim. Neither is a predicate M4 owns. +#: and `initial` is the initial reading. Neither is a predicate M4 owns. #: Shared here beside WorkToken.tags for the reason COMMA_CHARS is: #: assign's `_WORD_ALREADY_CLAIMED` is built from this pair, and the #: two stages must not drift (post_rules imports _assign, so _assign diff --git a/nameparser/_types.py b/nameparser/_types.py index d7543d2b..2b0b0e8c 100644 --- a/nameparser/_types.py +++ b/nameparser/_types.py @@ -84,9 +84,12 @@ def __add__(self, other: object) -> NoReturn: # type: ignore[override] #: NOT to reproduce `family_particles`, which since #404 also consults #: UNJOINED_TAG and excludes a particle standing alone in its part #: ("Anh Do" has a particle-tagged family word and no family -#: particles); "conjunction" a joining word ("and", "y"); "initial" an -#: initial-shaped word in a script that HAS initials -- "J." or "А.", -#: never "씨." (#320); +#: particles); "conjunction" a joining word ("and", "y"); "initial" a +#: word READ as an initial -- initial-shaped in a script that HAS +#: initials ("J." or "А.", never "씨.", #320), or a marked +#: single-letter connective in a name written in one case (rules.md#P3 +#: says a single-letter connective reads as an initial where the +#: writing says so -- see CONJUNCTION_OR_INITIAL); #: "joined" a continuation of the token before it -- within one #: merged piece the tag is role-blind and every view joins the pair #: with a space ("Ph." + "D."; 'Smith, Ph. D. Smith' gives first_list @@ -459,14 +462,20 @@ class AmbiguityKind(StrEnum): #: reports -- ``Lexicon.conjunctions_ambiguous``, "e" by default -- #: because a letter outside it is not in doubt: "JUAN GARCIA Y #: LOPEZ" joins into family "GARCIA Y LOPEZ" and reports nothing, - #: as does the Cyrillic "ХОСЕ И МАРИЯ САНТОС". - #: Three things never reach the fork. MIXED-case input decides on + #: as does the Cyrillic "ХОСЕ И МАРИЯ САНТОС" -- whose join lands + #: in GIVEN "ХОСЕ И МАРИЯ" rather than FAMILY, but reports nothing + #: all the same. + #: Four things never reach the fork. MIXED-case input decides on #: the writing instead, so "Jose E Maria Santos" reads the capital #: as an initial and "John e Smith" the lowercase letter as the #: connective, neither reporting. A CASELESS letter has no case to - #: read, so Arabic "محمد و علي" keeps its connective silently. And a + #: read, so Arabic "محمد و علي" keeps its connective silently. A #: MULTI-letter connective ("and", "та", "και") or a symbol ("&") is - #: no initial's shape at any casing. + #: no initial's shape at any casing. And a letter inside a maiden or + #: delimited clause never reaches the fork either (the own-words + #: doctrine, rules.md#P3): appending " née Jones" or a nickname + #: changes no report, because the clause's words were never + #: eligible for it. #: ``detail`` names the token, the kind naming neither the field nor #: the letter: which field the reading lands in follows the name's #: shape and its ``name_order``, the PARTICLE_OR_GIVEN precedent. diff --git a/nameparser/config/conjunctions.py b/nameparser/config/conjunctions.py index 4164b68a..2ed2448c 100644 --- a/nameparser/config/conjunctions.py +++ b/nameparser/config/conjunctions.py @@ -47,10 +47,10 @@ # blessed their joining and nothing here narrows it. # # 'i' (Catalan) is NOT here because it is not conjunction vocabulary - # at all yet; it ships in this subset when #397 adds it, a bare I + # at all yet; it ships in this subset if #397 adds it, a bare I # initial being as common as a bare E. # - # A caller edits the policy rather than a switch: remove 'e' to + # A caller edits the vocabulary rather than a switch: remove 'e' to # restore joining for Portuguese data, add 'y' for a Dutch-style # "every single letter is an initial". 'e', diff --git a/tests/test_capitalization.py b/tests/test_capitalization.py index 264bcfb7..0ddb85af 100644 --- a/tests/test_capitalization.py +++ b/tests/test_capitalization.py @@ -241,7 +241,7 @@ def test_capitalize_all_particle_family_beside_a_conjunction(self) -> None: # count collapses and the direction flips, uppercase now differing # on fewer names than lowercase rather than more. The dated # re-measurement and its recipe (with the actual counts) live - # under decisions.md#R5, written in commit C. + # under decisions.md#R5. # # THROUGH 2.3.0 the mechanism was v1's initial carve-out, taken in # the PARSE since #458 and read off the tag by the repair: a word @@ -295,7 +295,8 @@ def test_forcing_repair_ignores_the_case_it_was_given(self) -> None: # 'JUAN Y GARCIA' and 'juan y garcia' now repair to the same # string and the property above holds for them too. 1.4.0's # 'Juan Y Garcia' (measured on the released wheel) is the parity - # break this PR's ledgers classify. + # break decisions.md#R5's 2026-09-13 amendment records; no ledger + # compares case repair, which is why this test is the pin. # # The name is kept for the blame trail even though it now # overstates: what decides repair is the NAME's case class, not diff --git a/tests/v2/cases.py b/tests/v2/cases.py index 53e1c956..d6474623 100644 --- a/tests/v2/cases.py +++ b/tests/v2/cases.py @@ -1015,7 +1015,8 @@ def _check_cjk_shape_purity(self) -> None: # lower trio ('john e smith' / 'JOHN E SMITH' / 'John e Smith'); # its mixed-upper twin ('John E Smith') is absent because it would # pin nothing these rows do not -- the three-word carve-out already - # keeps 'e' a name word either way, exactly as row 5 does. 'y' + # keeps 'e' a name word either way, exactly as + # one_case_three_word_e_is_an_initial does. 'y' # carries one-case-upper and one-case-lower at four words ('JUAN # GARCIA Y LOPEZ' / 'juan garcia y lopez'), mixed-upper at four # words where 'Y' still vetoes ('Juan Garcia Y Lopez'), and one- @@ -1028,8 +1029,9 @@ def _check_cjk_shape_purity(self) -> None: # 'Juan Y Garcia' (mixed-UPPER, three words) is absent because its # control lives beside the capitalize() pin instead, in # tests/test_capitalization.py::test_a_one_letter_conjunction_is_case_sensitive_to_repair; - # 'Juan y Garcia' (mixed-lower, three words) is absent everywhere as - # an input. + # 'Juan y Garcia' (mixed-lower, three words) is absent from this + # table because rules.md#P3 carries it as its boundary example and + # test_render.py pins its initials. # # The other five rows are three DIFFERENT reasons a row does not # move, not "three scripts that must not enter the fork": the @@ -1196,7 +1198,7 @@ def _check_cjk_shape_purity(self) -> None: notes="pinned at TODAY's reading so #397 shows its move: 'i' " "is not in CONJUNCTIONS at all, so the bare capital is " "an initial by shape and this row never reaches the " - "fork. When #397 adds 'i' it ships in " + "fork. If #397 adds 'i' it ships in " "conjunctions_ambiguous too, and this row changes", shape=1), Case("catalan_i_is_not_connective_vocabulary_lower", diff --git a/tests/v2/pipeline/test_classify.py b/tests/v2/pipeline/test_classify.py index f063e55a..fbc2171c 100644 --- a/tests/v2/pipeline/test_classify.py +++ b/tests/v2/pipeline/test_classify.py @@ -27,7 +27,11 @@ conjunctions=frozenset({"and", "e", "y", "й"}), conjunctions_ambiguous=frozenset({"e"}), bound_given_names=frozenset({"abdul"}), - maiden_markers=frozenset({"née"}), + # "née" and the unaccented "nee" are both shipped (English writes + # the French marker either way, AGENTS.md); a clause-truncation + # test below needs the unaccented spelling since _normalize does + # not fold accents away. + maiden_markers=frozenset({"née", "nee"}), ) @@ -184,9 +188,16 @@ def test_an_orphan_marker_is_inert_even_in_the_emitter() -> None: def test_a_caseless_connective_never_enters_the_fork() -> None: # Arabic و has no case, so token.upper() == token.lower() and - # today's rule stands whatever the name's case class is. + # today's rule stands whatever the name's case class is. Marked + # as well -- the only configuration where the casedness gate + # decides anything: without this, و is absent from + # conjunctions_ambiguous the same way every other shipped + # conjunction but 'e' is, so the test would pass even if the + # casedness check were deleted outright. lex = dataclasses.replace( - _LEX, conjunctions=_LEX.conjunctions | frozenset({"و"})) + _LEX, + conjunctions=_LEX.conjunctions | frozenset({"و"}), + conjunctions_ambiguous=_LEX.conjunctions_ambiguous | frozenset({"و"})) out = _classified_with("محمد و علي", lex) assert "conjunction" in _tags(out, "و") assert out.ambiguities == () @@ -268,3 +279,81 @@ def test_a_word_after_the_maiden_marker_reads_as_plain_vocabulary() -> None: assert "conjunction" in _tags(out, "e") assert "initial" not in _tags(out, "e") assert out.ambiguities == () + + +def test_a_clauses_own_marker_word_does_not_truncate_the_own_words() -> None: + # #527 review: _tag_marker_runs walks every token, so a maiden + # marker WORD that arrives already ROLED (parenthesised clause + # content, extract's doing -- WorkToken.role's docstring) is the + # CLAUSE's own word, not a bare marker opening a new clause, and + # must not move clause_at. Before the fix, "NEE"'s match truncated + # "own" at its own index, excluding the trailing "GARCIA Y LOPEZ" + # from the case class and misreading 'Y' as an initial instead of + # joining the family (rules.md#P4). + out = _classified("JUAN (NEE JONES) GARCIA Y LOPEZ") + nee = next(t for t in out.tokens if t.text == "NEE") + # the parenthesised clause opens as Role.NICKNAME (parens are a + # nickname delimiter by default) and extract promotes it to + # Role.MAIDEN once the marker word is recognized inside it (M1/M3) + assert nee.role is Role.MAIDEN + assert "conjunction" in _tags(out, "Y") + assert "initial" not in _tags(out, "Y") + assert out.ambiguities == () + + +def test_a_clauses_own_marker_word_does_not_skew_the_case_class() -> None: + # the mixed-case sibling of the test above: excluding "Santos" (a + # trailing capitalized own word) from "own" made the truncated + # prefix "jose e maria" look uniformly lowercase, so 'e' wrongly + # took the one-case fork and reported. + out = _classified('jose e maria "Nee" Santos') + nee = next(t for t in out.tokens if t.text == "Nee") + # a lone marker word in the clause: no content follows it inside + # the quotes, so nothing promotes NICKNAME to MAIDEN (M3) -- the + # fix does not care which role a clause carries, only that it + # carries one at all (WorkToken.role is not None) + assert nee.role is Role.NICKNAME + assert "conjunction" in _tags(out, "e") + assert "initial" not in _tags(out, "e") + assert out.ambiguities == () + + +def test_a_title_counts_toward_the_case_class_unlike_a_clause() -> None: + # a title is one of the name's own words, unlike a clause: "Dr." + # beside an all-upper name makes the WHOLE name mixed case, so 'Y' + # is evidence-backed and stays an initial; "DR." (itself all-caps) + # leaves the name one-case and 'Y' joins as the plain connective. + mixed = _classified("Dr. JUAN GARCIA Y LOPEZ") + assert "initial" in _tags(mixed, "Y") + assert "conjunction" not in _tags(mixed, "Y") + assert mixed.ambiguities == () + one_case = _classified("DR. JUAN GARCIA Y LOPEZ") + assert "conjunction" in _tags(one_case, "Y") + assert "initial" not in _tags(one_case, "Y") + assert one_case.ambiguities == () + + +def test_the_emitter_runs_per_token() -> None: + # each cased single-letter connective gets its OWN report, keyed by + # its own index -- 'e and e' in the corpus is the other witness. + out = _classified("jose e maria e santos") + kinds = [a.kind for a in out.ambiguities] + assert kinds == [AmbiguityKind.CONJUNCTION_OR_INITIAL, + AmbiguityKind.CONJUNCTION_OR_INITIAL] + assert out.ambiguities[0].indices == (1,) + assert out.ambiguities[1].indices == (3,) + + +def test_classify_is_per_token_independent_of_the_comma() -> None: + # classify runs before the comma settles any structural question, + # so the case class and the fork read the same whether the comma + # is there or not. + family_comma = _classified("SANTOS, JOSE E MARIA") + assert "initial" in _tags(family_comma, "E") + kinds = [a.kind for a in family_comma.ambiguities] + assert kinds == [AmbiguityKind.CONJUNCTION_OR_INITIAL] + + suffix_comma = _classified("GARCIA Y LOPEZ, JUAN") + assert "conjunction" in _tags(suffix_comma, "Y") + assert "initial" not in _tags(suffix_comma, "Y") + assert suffix_comma.ambiguities == () diff --git a/tests/v2/test_contracts.py b/tests/v2/test_contracts.py index 5466ecf4..3591a89a 100644 --- a/tests/v2/test_contracts.py +++ b/tests/v2/test_contracts.py @@ -244,8 +244,3 @@ def test_the_documented_replacements_for_an_in_place_edit_work() -> None: # recipe 2: an extended Lexicon for the 2.0 API parser = Parser(lexicon=Lexicon.default().add(titles={"dean"})) assert parser.parse("Dean Smith").title == "Dean" - - -def test_conjunction_or_initial_is_a_stable_string() -> None: - # A StrEnum member IS its value; the value is API and never changes. - assert AmbiguityKind.CONJUNCTION_OR_INITIAL == "conjunction-or-initial" diff --git a/tests/v2/test_ledger_guards.py b/tests/v2/test_ledger_guards.py index 39720c9a..6a026840 100644 --- a/tests/v2/test_ledger_guards.py +++ b/tests/v2/test_ledger_guards.py @@ -1067,7 +1067,13 @@ def test_case_shape_ids_exist_in_the_inventory() -> None: "fix(#383/#479) a single-letter connective joins only on case evidence": ("Jose e Maria Santos", "Jose E Maria Santos", "Juan Garcia y Lopez", "Juan Garcia Y Lopez", "John e Smith", - "juan garcia y lopez", "JUAN Y GARCIA"), + "juan garcia y lopez", "JUAN Y GARCIA", + # a title counts toward the case class the same as any other + # of the name's own words (unlike a clause) -- "Dr." beside + # an all-upper name makes the whole name mixed case, so 'Y' + # is evidence-backed and stays an initial; the literal rule + # must never claim this + "Dr. JUAN GARCIA Y LOPEZ"), # 'john e jones, III' is the probe worth understanding: the # trailing uppercase III makes the whole name mixed-case, so it # keeps today's reading and no rule of this change may ever claim diff --git a/tests/v2/test_render.py b/tests/v2/test_render.py index f56131ea..5e5240bc 100644 --- a/tests/v2/test_render.py +++ b/tests/v2/test_render.py @@ -693,3 +693,16 @@ def test_facade_initials_do_not_yet_follow_the_one_case_fork() -> None: """ assert parse("john e smith").initials() == "j. e. s." assert HumanName("john e smith").initials() == "j. s." + + +def test_y_side_initials_of_the_one_case_fork() -> None: + """The other direction of the split above, pinned on 'y' rather + than 'e': the core follows the fork ('Y' is a plain conjunction in + a one-case name and contributes no initial, rules.md#R3), and the + facade still does not. + """ + assert parse("JUAN Y GARCIA").initials() == "J. G." + assert parse("JUAN GARCIA Y LOPEZ").initials() == "J. G. L." + # the split, the other direction: the facade's HumanName still + # reads a bare capital connective as an initial (1.4.0 parity) + assert HumanName("JUAN Y GARCIA").initials() == "J. Y. G." From 3bde6f2b026491ae19660c488f77a29115ce0f82 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Sun, 13 Sep 2026 17:40:58 -0700 Subject: [PATCH 5/6] refactor(#383/#479): one list fewer in classify, and two review items Simplifications, all behavior-preserving (7956 passed, five gates 0 unexplained, call count 412->411 parse / 449->448 facade): - classify() dropped its `texts` list. `own` now slices `state.tokens` up to clause_at instead of indexing a parallel text list, which is one comprehension and one paragraph of justification fewer. The frame-cost note that mattered (is_one_case takes a Sequence, #475) moves down beside `own`, where the list is actually built. - `_tags_for`'s mixed-case branch calls is_initial once, not twice; a conjunction token was paying for the second call. - `cased_single` -> `single_letter_connective`, the term rules.md#P3 uses for what the expression tests. - the comment above the per-token fork argument named a variable `own_word` that does not exist. Accepted review items: - _types.py's `initial` note quoted P3's head clause while describing only the one-case branch. Now names the branch and points, matching docs/modules.rst's twin. - decisions.md: a dated amendment in the P3 section, since #445's 2026-08-27 entry calls `initial` "the `initial` shape tag" and that stopped being the whole truth when a marked connective started earning the tag without the shape (rules.md#M4 says "read as"). Co-Authored-By: Claude Fable 5.1 --- docs/design/decisions.md | 2 ++ nameparser/_pipeline/_classify.py | 39 ++++++++++++++----------------- nameparser/_types.py | 5 ++-- 3 files changed, 22 insertions(+), 24 deletions(-) diff --git a/docs/design/decisions.md b/docs/design/decisions.md index f50ae00c..56005c8c 100644 --- a/docs/design/decisions.md +++ b/docs/design/decisions.md @@ -333,6 +333,8 @@ The reconciled v1-style banks (`tests/test_*.py`) carried eight `@pytest.mark.xf - 2026-09-13 #383/#479 — `conjunctions_ambiguous` is deliberately NOT registered in `_SUBSET_FIELDS`, so an orphan marker entry raises nothing. An orphan there is INERT rather than harmful: the classify fork tests membership in `conjunctions` before it reads the subset, and the emitter tests it again, so a marker entry whose base word is gone is never consulted by either. AGENTS.md's invariants rule guards harm, not no-ops, and the precedent is this file's #given-name-titles Declined entry, where two attempts at a check each cost a working configuration to forbid a condition that costs nothing. What holds the SHIPPED constant to the subset relation is an import-time assert in `nameparser/config/conjunctions.py` (which also holds it to cased single letters, the only entries the fork can read), and the v1 shim's intersection, which is a provable no-op. So `remove(conjunctions={"e"})` simply works, leaving a stale marker entry behind that does nothing. Pinned by `test_removing_a_conjunction_leaves_its_ambiguous_marker_alone` in tests/v2/test_lexicon.py and by the orphan behavioral pin in tests/v2/pipeline/test_classify.py, both of which cite this entry. +- 2026-09-13 #383/#479 — AMENDS the 2026-08-27 #445 entry under M4 below, which calls `initial` "the `initial` shape tag": since this change the tag is no longer purely a shape claim, because a marked single-letter connective in a one-case name carries it without being initial-SHAPED. rules.md#M4's statement now says "read as an initial" for that reason, and M4's carve-out itself is unchanged — it still reads the tag classify recorded rather than a predicate of its own. + - Provenance: the single-letter-connective guard is v1's fix for Google Code issue 11 ("john e smith", 2013, commit 33676c9) — the "#11" citations that circulated pointed at a GitHub accident, not the real source. Recorded so the archaeology stays done. Excluded (Lexicon.conjunctions_ambiguous, the marked half of nameparser/config/conjunctions.py — an entry here reads as an INITIAL in a name written wholly in one case): diff --git a/nameparser/_pipeline/_classify.py b/nameparser/_pipeline/_classify.py index 9181fc33..12498f53 100644 --- a/nameparser/_pipeline/_classify.py +++ b/nameparser/_pipeline/_classify.py @@ -93,10 +93,10 @@ def _tags_for(token: WorkToken, n: str, state: ParseState, # written wholly in one case, where nothing says so — where the # letter is one the vocabulary marks as reading both ways" # (#383/#479; history: decisions.md#P3) - cased_single = (len(token.text) == 1 - and token.text.upper() != token.text.lower() - and n in lex.conjunctions) - if cased_single and one_case_own: + single_letter_connective = (len(token.text) == 1 + and token.text.upper() != token.text.lower() + and n in lex.conjunctions) + if single_letter_connective and one_case_own: # No case evidence, so the vocabulary decides. No namespaced # tag beside it: the emitted ambiguity IS the record of the # decision (mechanisms.md#MARK-DONT-STRIP is satisfied by the @@ -113,9 +113,10 @@ def _tags_for(token: WorkToken, n: str, state: ParseState, # the mixed-case rule, unchanged. v1's is_conjunction excludes # initials: 'e.' in 'john e. smith' is a middle initial, not # the Spanish conjunction 'e' - if n in lex.conjunctions and not is_initial(token.text): + initial = is_initial(token.text) + if n in lex.conjunctions and not initial: tags.add("conjunction") - if is_initial(token.text): + if initial: tags.add("initial") if n in lex.bound_given_names: tags.add("vocab:bound-given") @@ -236,13 +237,8 @@ def _tag_marker_runs(state: ParseState, def classify(state: ParseState) -> ParseState: # One fold per token, shared by the marker pass and the vocabulary # tags -- the shape suffix_as_written already asks for ("n is - # _normalize(text), passed in so callers normalize once"). `texts` - # is a LIST, not a generator, because `own` below indexes it by - # position (`texts[i]`); is_one_case's own `Sequence` parameter is - # where the frame-cost argument for passing a built sequence lives - # (_vocab.py, #475). - texts = [t.text for t in state.tokens] - folded = [_normalize(x) for x in texts] + # _normalize(text), passed in so callers normalize once"). + folded = [_normalize(t.text) for t in state.tokens] marker_tags = _tag_marker_runs(state, folded) # rules.md#P3 says a maiden marker, taken as one, and the words it # takes, are not among the name's own words -- so clause_at is the @@ -256,7 +252,7 @@ def classify(state: ParseState) -> ParseState: # matching entry is a clause start, so a min() over them in any # order would agree with this walk -- the walk just takes the # cheaper path given the order this dict happens to arrive in. - clause_at = len(texts) + clause_at = len(state.tokens) for i, tag in marker_tags.items(): # A marker word already carrying a role arrived pre-set by # extract (WorkToken.role's docstring) -- it is the CLAUSE's @@ -276,14 +272,15 @@ def classify(state: ParseState) -> ParseState: # not flip the reading of words that did not change. Not stored on # ParseState: nothing downstream reads it today, and #289/#516 can # promote it the way `order` was recorded rather than recomputed. - own = [texts[i] for i, t in enumerate(state.tokens) - if i < clause_at and t.role is None] + # is_one_case's own `Sequence` parameter is where the frame-cost + # argument for handing it a built list lives (_vocab.py, #475). + own = [t.text for t in state.tokens[:clause_at] if t.role is None] one_case = is_one_case(own) - # The fork itself must not read a clause's words either: `own_word` - # is the same "own words" test as `own` above, applied per token so - # the fork and its emitter agree with the case class they consult. - # No extra frame -- it is one more boolean in a comprehension - # that already walks every token. + # The fork itself must not read a clause's words either, so the + # `one_case and ...` argument below repeats `own`'s membership test + # per token, and the fork and its emitter then agree with the case + # class they consult. No extra frame -- it is one more boolean in a + # comprehension that already walks every token. tokens = tuple( dataclasses.replace( t, tags=_tags_for(t, folded[i], state, marker_tags.get(i), diff --git a/nameparser/_types.py b/nameparser/_types.py index 2b0b0e8c..3d076a1e 100644 --- a/nameparser/_types.py +++ b/nameparser/_types.py @@ -87,9 +87,8 @@ def __add__(self, other: object) -> NoReturn: # type: ignore[override] #: particles); "conjunction" a joining word ("and", "y"); "initial" a #: word READ as an initial -- initial-shaped in a script that HAS #: initials ("J." or "А.", never "씨.", #320), or a marked -#: single-letter connective in a name written in one case (rules.md#P3 -#: says a single-letter connective reads as an initial where the -#: writing says so -- see CONJUNCTION_OR_INITIAL); +#: single-letter connective in a name written in one case +#: (rules.md#P3, see CONJUNCTION_OR_INITIAL); #: "joined" a continuation of the token before it -- within one #: merged piece the tag is role-blind and every view joins the pair #: with a space ("Ph." + "D."; 'Smith, Ph. D. Smith' gives first_list From f4d297213ffca1c7aec1e86ff5497d0f36e2776b Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Sun, 13 Sep 2026 17:44:36 -0700 Subject: [PATCH 6/6] docs(review): a renamed local and a pointer to the P3 amendment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two one-line items from the final review pass on PR #527: the Catalan row's note in tests/v2/cases.py named the local renamed to `single_letter_connective` in 3bde6f2, and the 2026-08-27 #445 entry under decisions.md#M4 now says, beside "the `initial` shape tag", that it is amended 2026-09-13 under P3 — a reader landing on the dated entry meets the pointer there rather than 455 lines away. Co-Authored-By: Claude Fable 5.1 --- docs/design/decisions.md | 2 +- tests/v2/cases.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/design/decisions.md b/docs/design/decisions.md index 56005c8c..73120af2 100644 --- a/docs/design/decisions.md +++ b/docs/design/decisions.md @@ -788,7 +788,7 @@ Declined: - 2026-08-27 #445 (M4, PR on fix/445-maiden-marked-lone-name) — Derek's rationale, in his terms: normally if there is only one name we assume it is a given name because we have to pick one, but maiden markers mark a previous surname before marriage, so it would not make sense to include one if there was no other surname for it to clarify — and given that, when a marker leaves only one name word we switch the assumption and read that word as the family name. `parse("Smith née Jones")` reported given 'Smith' with an EMPTY family through 2.1 and reads family 'Smith', maiden 'Jones' since. The rule is written in post_rules as H1's sibling and placed directly under it, so the interaction is decidable by reading rather than by running: where H1 fired there is no GIVEN left for M4 to move, and where H1 declined because the title addresses by given name ("Sir John née Jones") the `not titles` guard keeps M4 off the same word. A titled name is H1's at both outcomes, which is what keeps H1's given-name-title carve-out intact. - 2026-08-27 #445 — the third time in the 2.2 bundle that a name carrying a maiden clause reported no family, and the three share a SYMPTOM rather than a cause. State that carefully, because the first draft of this entry claimed one cause for all three and was wrong. #410 (H1): a title plus one name word reported no family the moment a suffix, nickname or maiden name stood beside it. #411 (P5's reserve): the bound given-name join counted the marker and the maiden name as words it could spend, and spent the family name. Those two ARE one cause -- a count that included words which are no part of the name -- and the grep that would find a fourth is a rule counting name words without first asking which of them are name words. #445 is not that: it changes no count anywhere (post_rules gains a new block, assign gains only a comment), and `parse("Smith")` with no clause beside it at all already read given 'Smith' with an empty family, so nothing was being swallowed. Its cause is the one the next entry gives: O4 decides nothing at one name word, and the reading came from a convention nobody had written down. The site that grep would miss is `_name_positions`'s `count == 1` branch, silent for every order and every name -- which is exactly what O5 now records. - 2026-08-27 #445 — O4 was SILENT at exactly one name word, which is why the shipped reading had no rule to point at and why O5 now exists. O4 reads a name by comparing where its words stand ("the first name word is the given name, the last is the family name"); with one word the first IS the last, so there is nothing to compare and the rule decides nothing. O5 records the reading as the convention it is — a guess fixed in advance so that the same input always reads the same way, not a determination about the word — and names H1, N3 and M4 as the rules that DO decide such a name. Written that way deliberately: a rule asserting "a lone name word is the given name" as a fact would have made #445 a contradiction of the documented behavior instead of an exception to a documented guess, and would make #449 one too. -- 2026-08-27 #445 — the two carve-outs are not inventions, and both rest on mechanisms.md#TWO-LAYER-ASSIGN: the positional layer never overrides a vocabulary claim, and M4 changes only what POSITION decided. A word the vocabulary claims as a given name keeps that reading ('abd née Jones' — `vocab:bound-given`), and so does a word written as an initial ('J. née Jones Smith V' — the `initial` shape tag). Both are read off tags classify already recorded rather than off a predicate of M4's own, and both witnesses are real corpus names rather than constructed cases, so each carve-out has something in the differential that would notice its loss. Mutation-checked on a scratch copy: dropping the `initial` carve-out fails that name's case row, its facade twin, M4's own boundary example and the pre-existing assertion in test_the_chain_and_the_walk_stop_where_the_peel_begins; dropping `vocab:bound-given` fails the other name's two runners, M4's boundary, P5's own doc example, O5's own `abd née Jones` line and the corpus-wide maiden-clause property on 'abdul' -- six in all, the O5 line having been added by the review round, and the list is exhaustive as re-run against the final tree. +- 2026-08-27 #445 — the two carve-outs are not inventions, and both rest on mechanisms.md#TWO-LAYER-ASSIGN: the positional layer never overrides a vocabulary claim, and M4 changes only what POSITION decided. A word the vocabulary claims as a given name keeps that reading ('abd née Jones' — `vocab:bound-given`), and so does a word written as an initial ('J. née Jones Smith V' — the `initial` shape tag; amended 2026-09-13 under P3: the tag is no longer purely a shape claim). Both are read off tags classify already recorded rather than off a predicate of M4's own, and both witnesses are real corpus names rather than constructed cases, so each carve-out has something in the differential that would notice its loss. Mutation-checked on a scratch copy: dropping the `initial` carve-out fails that name's case row, its facade twin, M4's own boundary example and the pre-existing assertion in test_the_chain_and_the_walk_stop_where_the_peel_begins; dropping `vocab:bound-given` fails the other name's two runners, M4's boundary, P5's own doc example, O5's own `abd née Jones` line and the corpus-wide maiden-clause property on 'abdul' -- six in all, the O5 line having been added by the review round, and the list is exhaustive as re-run against the final tree. - 2026-08-27 #445 — the two halves of Derek's answer differ in their relationship to 1.4.0, and a reader should not have to re-derive it. The bracketed spelling RESTORES v1: `Smith (née Jones)` read family 'Smith', nickname 'née Jones' on 1.4.0, 2.0.0 and 2.1.0 alike, and now reads family 'Smith', maiden 'Jones' — the clause changes hands and the family name stays put, so the 1.4.0 gate's diff on that name SHRANK to {nickname, maiden}. The interior spelling is a NEW reading: `Jane née Jones Smith` read first 'Jane', middle 'née Jones', last 'Smith' on 1.4.0 and reads family 'Jane', maiden 'Jones Smith' now, the real surname being inside the maiden value by M2's greedy take. That the marker stands inside the name changes nothing for M4, which counts what the take LEAVES rather than where the marker stood — one name word left that way is one name word, which is the widest half of the decision and the half no earlier version agrees with. - 2026-08-27 #445 — a precedence claim corrected while drafting, and it is exactly the class this bundle keeps producing. The first draft of M4 said a name carrying a nickname is N3's rather than this rule's. Measured false: `'Smitty' Jones Jr. née Smith` reads family 'Jones', because N3's count does not set a suffix aside, so N3 declines and M4 fires. A nickname holds nothing off — where N3 has already named the family M4 finds nothing left to move, and where N3 declined M4 names it. The general lesson is the one #410 and #411 already taught from the other side: a rule that counts name words and a rule that counts something else will disagree at the edges, and the disagreement is only visible if the interaction is measured rather than asserted. - 2026-08-27 #445 — what the ledger's `# revisit when #445 lands` markers bought, and what they got wrong. Four blocks carried them, and each predicted that a "keep the family" fix would leave the diff a subset of the fields its rule already declared, so the rule would go on explaining the name and the change would be absorbed with the gate green. That was wrong in a way the rule's own shape settles: M4 MOVES the one name word rather than adding one, so `given` empties as `family` fills, the diff outgrows every declaration, and eight names arrived UNEXPLAINED at all three baselines. Two counts run through this and they are different sets, so name which is which: NINE corpus names change reading, of which eight arrive as new diffs — the ninth, 'Smith (née Jones)', has its 1.4.0 diff SHRINK instead, its family agreeing with v1 again. The four markers between them named NINE names to re-measure, of which seven moved: the two that did not are M4's carve-outs, and the two movers the markers could not name were not corpus names until this change's own examples added them. The markers earned their keep all the same — they named the right rule, the right field and very nearly the right set — so they are corrected in place rather than deleted, and the correction says what actually happened. THREE of the four moved in the opposite direction from their own prediction, and getting that number right took two passes. The fix(#335) rule for 'Smith (née Jones)' appears in all three ledgers, and every copy feared absorbing a WIDENED diff while the diff in fact SHRANK to {nickname, maiden}. The first pass narrowed only the 1.4.0 copy, on a measurement of that wheel, and left the other two at four fields on the reasoning that 2.0.0 and 2.1.0 'read `given`, and the baseline cannot move'. They did not: only the BARE spelling ever read `given`, and this rule holds the bracketed one, which reads family 'Smith', nickname 'née Jones' on every released version. So two rules stood over a two-field diff declaring four, and classify's subset test would have explained a `given`/`family` regression on that name in silence -- the absorption those very notes existed to prevent, surviving the round that answered them. A third reviewer found it by instrumenting the comparator to dump real diff-field sets, and that is the lesson worth keeping: ledger prose cannot be checked by reading it, and the round that corrects a marker is exactly where a wrong premise gets written down with confidence. diff --git a/tests/v2/cases.py b/tests/v2/cases.py index d6474623..1f747cd0 100644 --- a/tests/v2/cases.py +++ b/tests/v2/cases.py @@ -1039,7 +1039,7 @@ def _check_cjk_shape_purity(self) -> None: # vocabulary and takes the non-member branch exactly as 'y' does, # joining without a report -- the Catalan pair's 'i' is Latin script # but is not conjunction vocabulary AT ALL, so it never reaches - # `cased_single` in the first place (a different, earlier exclusion + # `single_letter_connective` in the first place (a different, earlier exclusion # than Cyrillic's, and #397's before-picture); only the Arabic row # is genuinely caseless and so never enters the fork on that # ground.