diff --git a/AGENTS.md b/AGENTS.md index 6f007b58..09abd30b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -242,7 +242,7 @@ Add a dedicated `copy.deepcopy()` round-trip test for it too (see `test_regexes_ **Unknown-key attribute access on `TupleManager`/`RegexTupleManager` warns (1.4, #256) — 2.0 note: the warning became a hard `AttributeError` naming the known keys (shim `TupleManager`); the paragraph below describes the deleted v1 machinery and applies only when reading v1 history.** — a key not currently in the dict emits `DeprecationWarning` naming the miss and the known keys (`_warn_unknown_key`), before falling back to the same `None`/`EMPTY_REGEX` default as before; `.get()` stays silent. Dunder probes (`__deepcopy__`) still raise `AttributeError` outright, and single-underscore probes (`_repr_html_`, IPython's `_ipython_canary_method_should_not_exist_`, etc.) are excluded from the warning too — no real config key starts with `_`, so both guards just keep protocol/introspection probes from misfiring as "typo" warnings. This means internal parser code that reads `self.C.regexes.` unconditionally (e.g. `squash_bidi`'s `bidi`) now warns if a caller's custom `regexes` dict omits that key — a previously-silent partial-override pattern is on the same deprecation path as an actual typo. -**Adding a word to a config set** — first check the *other* sets for the same word (grep `nameparser/config/` or intersect the sets in a `python3 -c`). Real overlaps exist: `do`/`st`/`mc` ∈ `PARTICLES` ∩ `TITLES`/`SUFFIX_ACRONYMS`; `abd` = "ABD" ∈ `SUFFIX_ACRONYMS`; `abu` ∈ `PARTICLES` ∩ `BOUND_GIVEN_NAMES` (position-dependent: leading token → first-name join, mid-name → last-name join). Usually position-dependent and harmless, but can force a guard or an exclusion (the `last_base` all-particles guard; dropping `abd` from `BOUND_GIVEN_NAMES`). +**Adding a word to a config set** — first check the *other* sets for the same word (grep `nameparser/config/` or intersect the sets in a `python3 -c`). Real overlaps exist: `do`/`st`/`mc` ∈ `PARTICLES` ∩ `TITLES`/`SUFFIX_ACRONYMS`; `abd` = "ABD" ∈ `SUFFIX_ACRONYMS`; `abu` ∈ `PARTICLES` ∩ `BOUND_GIVEN_NAMES` (position-dependent: leading token → given-name join, mid-name → family-name join). Usually position-dependent and harmless, but can force a guard or an exclusion (the `last_base` all-particles guard; dropping `abd` from `BOUND_GIVEN_NAMES`). **Before adding a short/common word to `PARTICLES` globally**, test it mid-string against realistic 3-token names, not just check for English-word collisions: Korean/Vietnamese given names put a short syllable in the middle slot (`Park In Hwan`, `Nguyen To Nga`), and Western names put a bare initial there (`John V. Smith`). A word that looks safe ("nobody is named 'to'") can still swallow a real middle name/initial into the last name once it's a global prefix — confirmed regressions for `to`/`in`/`an`/`ten`/`then` and bare `v` this way (PR #191). @@ -258,7 +258,7 @@ Add a dedicated `copy.deepcopy()` round-trip test for it too (see `test_regexes_ **Parsing must never write into `Constants`** — parse-time derived recognition (dotted abbreviations like "Lt.Gov.", conjunction-joined titles/prefixes like "Mr. and Mrs." / "von und zu") lives in per-instance `HumanName._derived_titles` / `_derived_suffixes` / `_derived_conjunctions` / `_derived_prefixes` sets, consulted by `is_title`/`is_suffix`/`is_conjunction`/`is_prefix`/`is_rootname`, reset at the start of `parse_full_name()`, and backfilled in `__setstate__` for pre-existing pickles. Never `self.C..add()` during parsing — `self.C` is usually the shared `CONSTANTS` singleton, so a write makes parse results order-dependent and thread-unsafe (this was a real bug through 1.2.x). `ParsingDoesNotMutateConfigTests` (`tests/test_constants.py`) enforces the invariant by snapshotting the whole config around a parse; it discovers collections structurally via `Constants.__getstate__()`, so new `Constants` collections are watched automatically — nothing to register. If a new derived category is ever needed: add a `_derived_*` set in `__init__`, reset it in `parse_full_name()`, backfill it in `__setstate__`, consult it in the matching `is_*` predicate (store `lc()`-normalized values, mirroring `SetManager`), and add a leak test with a name that triggers it. -**The `nameparser/config` vocabulary constants are frozen, and the 1.x names for them are a bridge for CALLERS only** (2.2, #293) — `TITLES.add("dean")` raises `AttributeError` at the line that writes it, so a runtime addition goes on a config OBJECT instead: `c = Constants(); c.titles.add("dean"); HumanName(name, constants=c)` for the v1 API, `Parser(lexicon=Lexicon.default().add(titles={"dean"}))` for the 2.0 one. Both are warning-free; mutating the shared `CONSTANTS` still works but warns. What the freeze buys is measured in `docs/migrate.rst`: `Lexicon.default()` is `functools.cache`d and reads the constants once, a v1 `Constants` copies them at every construction, and the shared `CONSTANTS` is a copy taken at import — so pre-freeze, an edit after the first parse reached only a freshly built `Constants`, an edit before any parse reached `parse()` and a fresh `Constants` but still not the shared singleton, and which of those happened depended on nothing the reader could see. Separately, every retired 1.x name (`PREFIXES`, `NON_FIRST_NAME_PREFIXES`, `BOUND_FIRST_NAMES`, `FIRST_NAME_TITLES`, `SUFFIX_NOT_ACRONYMS`) still resolves for a caller, with a `DeprecationWarning` naming its new path — but nothing inside `nameparser/` may spell one, in code OR in a comment: `tests/v2/test_config_aliases.py::test_no_internal_code_reads_a_retired_vocabulary_name` scans every `.py` in the package and fails on a hit outside the alias table that owns it. An internal read would also consume the once-per-process warning and leave the real caller told nothing. That scan does not reach `docs/` or this file, which is why the rename needed a prose sweep of its own. +**The `nameparser/config` vocabulary constants are frozen, and the 1.x names for them are a bridge for CALLERS only** (2.2, #293) — `TITLES.add("dean")` raises `AttributeError` at the line that writes it, so a runtime addition goes on a config OBJECT instead: `c = Constants(); c.titles.add("dean"); HumanName(name, constants=c)` for the v1 API, `Parser(lexicon=Lexicon.default().add(titles={"dean"}))` for the 2.0 one. Both are warning-free; mutating the shared `CONSTANTS` still works but warns. What the freeze buys is measured in `docs/migrate.rst`: `Lexicon.default()` is `functools.cache`d and reads the constants once, a v1 `Constants` copies them at every construction, and the shared `CONSTANTS` is a copy taken at import — so pre-freeze, an edit after the first parse reached only a freshly built `Constants`, an edit before any parse reached `parse()` and a fresh `Constants` but still not the shared singleton, and which of those happened depended on nothing the reader could see. Separately, every retired 1.x name (`PREFIXES`, `NON_FIRST_NAME_PREFIXES`, `BOUND_FIRST_NAMES`, `FIRST_NAME_TITLES`, `SUFFIX_NOT_ACRONYMS`) still resolves for a caller, with a `DeprecationWarning` naming its new path — but nothing inside `nameparser/` may spell one, in code OR in a comment: `tests/v2/test_config_aliases.py::test_no_internal_code_reads_a_retired_vocabulary_name` scans every `.py` in the package and fails on a hit outside the alias table that owns it. What an internal read costs is attribution: the warning names the file that performed the read, so the caller is told about a deprecation inside `nameparser/` they cannot act on, and a run under `-W error::DeprecationWarning` dies in library code. It does *not* cost the caller their own warning, and the reason is the warnings module rather than anything this bridge does: `__warningregistry__` lives in the READING module's globals, so repeats are suppressed per LOCATION, and a library read at `internal_reader.py:1` plus a caller read at `real_caller.py:1` produce two warnings — the same two that a plain `warnings.warn` from two files produces, verified against that control. What the bridge contributes is the *absence* of a write-back: `config/_deprecated.py` keeps serving retired names through `__getattr__` for the life of the process instead of caching the resolved value into module globals, which would silence every reader after the first — and the first is whoever imported earliest, routinely a dependency whose author is not the person who has to edit anything. Don't "optimize" that lookup. That scan does not reach `docs/` or this file, which is why the rename needed a prose sweep of its own. **`HumanName.C` is a property backed by `_C`, but pickles under the public key `'C'`** — `__init__`/direct assignment route through the `C` setter, which calls the shared `_validate_constants` staticmethod (also used by `__init__`) so an invalid value raises `TypeError` immediately instead of surfacing later as an unrelated `AttributeError` deep in parsing (#239). `__getstate__`/`__setstate__` deliberately translate `self._C` ↔ a `'C'` key in the pickled dict (with the usual `CONSTANTS`-singleton-becomes-`None` sentinel) rather than pickling `_C` directly, so the on-disk pickle format hasn't changed across this fix — don't "simplify" that translation away or old pickles/tests that hand-build a state dict with a `'C'` key will break. @@ -274,7 +274,7 @@ Add a dedicated `copy.deepcopy()` round-trip test for it too (see `test_regexes_ **A comparison that runs both sides in one tree reports 0 differences, which is what success looks like.** `tools/differential/` has been hardened against this — it generates its baseline worker into a temp dir, strips `PYTHONPATH` from the child, and aborts unless both the version AND the resolved path check out on each side; its README's three invocation traps are the analysis behind that design, worth reading before changing the harness. **The exposure is ad-hoc comparisons you write yourself**, where the same collapse has a cause the harness cannot disarm for you: **the shell's working directory persists between tool calls**, so a two-tree comparison written as two `cd`s silently runs both halves in whichever tree it landed in. Pin absolute paths, and assert `nameparser.__file__` on both sides the way `compare.py` does. The general rule outlives any particular trap: before believing a null result, prove the harness can report a difference — a clean run and a broken harness are the same output. -**`esq` is in both `SUFFIX_ACRONYMS` and `SUFFIX_WORDS` on purpose — do not "deduplicate" it, and do not describe it as two spellings** — the two branches normalize differently (see the asymmetry gotcha above): the word test strips only edge periods, the acronym test strips all of them. The load-bearing membership is the ACRONYM one, and it carries *both* spellings: `"Esq"` also survives `.replace(".","")` unchanged, so it is in `SUFFIX_ACRONYMS` too. The word membership is therefore inert as shipped — **provably**, not just on a sample: the intersection of the two sets is exactly `{esq}`, no `SUFFIX_WORDS` entry carries an interior period, and `suffixes.py` asserts `SUFFIX_ACRONYMS_AMBIGUOUS ∩ SUFFIX_WORDS == ∅`, so for a word in both, the acronym branch fires wherever the word branch does. Measured to match: `SUFFIX_WORDS − {esq}` changes **no** parse on either API, while `SUFFIX_ACRONYMS − {esq}` changes many and loses the family name on the multi-dot form (`"John Smith E.S.Q."` → `family='E.S.Q.'`). Deliberately no changed-parse COUNT here — three people built three "7 frames × 9 spellings" grids and got three different numbers (12, 15, 18); the count is a property of the grid, the zero and the direction are properties of the code. The word membership is still not junk: it is v1 data parity, and it is what keeps `"Esq"` matching for a caller who removes `esq` from `SUFFIX_ACRONYMS` themselves (verified — after `C.suffix_acronyms.remove('esq')`, `"John Smith Esq"` still parses `suffix='Esq'`, and dropping both memberships gives `family='Esq'`). Pinned by the `suffix_acronym_multidot_spelling` case-table row. The disjointness that IS asserted is `SUFFIX_ACRONYMS_AMBIGUOUS ∩ SUFFIX_WORDS`, which is a different claim: `suffix_as_written` ORs the branches, so a word membership bypasses the period gate the ambiguous set exists to impose. `suffixes.py`'s `# NOT asserted:` block states the same reasoning at the code — keep the two in step. +**`esq` is in both `SUFFIX_ACRONYMS` and `SUFFIX_WORDS` on purpose — do not "deduplicate" it, and do not describe it as two spellings** — the two branches normalize differently (see the asymmetry gotcha above): the word test strips only edge periods, the acronym test strips all of them. The load-bearing membership is the ACRONYM one, and it carries *both* spellings: `"Esq"` also survives `.replace(".","")` unchanged, so it is in `SUFFIX_ACRONYMS` too. The word membership is therefore inert as shipped — **provably**, not just on a sample: the intersection of the two sets is exactly `{esq}`, no `SUFFIX_WORDS` entry carries an interior period, and `suffixes.py` asserts `SUFFIX_ACRONYMS_AMBIGUOUS ∩ SUFFIX_WORDS == ∅`, so for a word in both, the acronym branch fires wherever the word branch does. Measured to match: `SUFFIX_WORDS − {esq}` changes **no** parse on either API, while `SUFFIX_ACRONYMS − {esq}` changes exactly the multi-dot spellings — `E.S.Q.`/`E.S.Q` move, losing the family name there (`"John Smith E.S.Q."` → `family='E.S.Q.'`), while every single-token spelling (`Esq`, `Esq.`, `ESQ`, `esq`) is untouched — and that half is a PROOF, not a sample, from the algebra just above: each normalizes to `esq` under the word test's edge-period strip, so `SUFFIX_WORDS` catches it whatever the acronym set holds. A 26-frame sweep found no exception, but the sweep only corroborates; the argument is what makes it safe to rely on. Deliberately no changed-parse COUNT here — four people built four grids of this shape and got four different numbers (12, 15, 18, 10); the count is a property of the grid, while the zero, the direction, and *which spellings move* are properties of the code. "Changes many" is what this line said before, and it is the shape of claim to avoid: it sounds measured, survives any grid, and tells the next person nothing about where to look. The word membership is still not junk: it is v1 data parity, and it is what keeps `"Esq"` matching for a caller who removes `esq` from `SUFFIX_ACRONYMS` themselves (verified — after `C.suffix_acronyms.remove('esq')`, `"John Smith Esq"` still parses `suffix='Esq'`, and dropping both memberships gives `family='Esq'`). Pinned by the `suffix_acronym_multidot_spelling` case-table row. The disjointness that IS asserted is `SUFFIX_ACRONYMS_AMBIGUOUS ∩ SUFFIX_WORDS`, which is a different claim: `suffix_as_written` ORs the branches, so a word membership bypasses the period gate the ambiguous set exists to impose. `suffixes.py`'s `# NOT asserted:` block states the same reasoning at the code — keep the two in step. **`Lexicon.given_name_titles` is deliberately unvalidated against `titles` — do not add a check** — the lookup key is the space-joined run of `Role.TITLE` tokens, built by the parse, and a conjunction inside a run is itself tagged `Role.TITLE`, so `"sir and dame"` is a matchable key whose middle word lives in `conjunctions`. A whole-entry check rejected multi-word entries; a per-word check rejected that one. No static relation over the vocabulary sets decides reachability. An unreachable entry is inert — nothing consults it and nothing misparses — so the condition being "guarded" costs the user nothing while each guard cost a working configuration. If a diagnostic is wanted, it must be non-blocking — the multi-word UserWarning in `_normset` is the shipped example; a raise remains wrong. Note the SHIPPED data cannot carry a multi-word given-name title without a spurious warning: `GIVEN_NAME_TITLES ⊆ TITLES` puts the entry in `titles` too, which is per-word warned; user-supplied v2 Lexicons are unaffected (`add(given_name_titles=...)` alone is silent). @@ -288,7 +288,7 @@ Add a dedicated `copy.deepcopy()` round-trip test for it too (see `test_regexes_ **`is_suffix()`'s period-stripping is asymmetric** — it does `lc(piece).replace('.', '')` (strips *all* periods) before checking `suffix_acronyms`, but only `lc(piece)` (leading/trailing only) before checking `suffix_not_acronyms`. Code that reimplements this check instead of calling `is_suffix()` must mirror both branches or it will misclassify acronym suffixes with internal-only periods (e.g. `"M.D"` with no trailing dot) — bit `parse_nicknames()`'s `handle_match()` in PR #189. -**`nickname_delimiters`/`maiden_delimiters` built-ins are string sentinels, not compiled patterns** — the three default keys (`quoted_word`, `double_quotes`, `parenthesis`) store the *name* of a `Constants.regexes` entry (a plain `str`), resolved via `getattr(self.C.regexes, name)` at parse time in `parse_nicknames()` — not the compiled `re.Pattern` itself. This is what lets `CONSTANTS.regexes.parenthesis = ...` keep affecting nickname/maiden parsing after construction, same as before this mechanism existed. Routing a built-in between buckets must be a `pop()` + assign (`maiden_delimiters['parenthesis'] = nickname_delimiters.pop('parenthesis')`) to carry that string sentinel over — copying `CONSTANTS.regexes['parenthesis']` directly into the new bucket instead would freeze it as a snapshot and silently stop tracking further `regexes` overrides. A key added by a caller for a *custom* delimiter is a real compiled pattern, distinguished at parse time via `isinstance(raw_pattern, re.Pattern)`. (#22) +**`nickname_delimiters`/`maiden_delimiters` built-ins are string sentinels, not compiled patterns** — each default key (`quoted_word`, `double_quotes`, `parenthesis`, and the rest) stores its own *name* as a plain `str`, and `Constants._snapshot()` maps that key to the `(open, close)` pair the 2.0 `Policy` carries (`_SENTINEL_PAIRS` in `_config_shim.py`). Routing a built-in between buckets is still a `pop()` + assign (`maiden_delimiters['parenthesis'] = nickname_delimiters.pop('parenthesis')`) to carry the sentinel over — measured, that flips `"Jane (Jones) Smith"` from `nickname='Jones'` to `maiden='Jones'`. **2.0 note:** the two things this bullet used to justify the sentinel with are both gone, and only the v1 history above still holds. `CONSTANTS.regexes.parenthesis = ...` now raises `TypeError` (2.0 configures parsing through named `Policy` flags), so the sentinel no longer buys post-construction tracking of a `regexes` override; and a caller's *custom* key is refused by `_DelimiterManager` — its message points at `Policy(nickname_delimiters=...)` — rather than accepted as a compiled pattern. No `isinstance(raw_pattern, re.Pattern)` branch survives, and `HumanName.parse_nicknames` is gone as a method — but do NOT sweep the name itself as dead: it is still live in `_facade.py`'s `_V1_HOOKS`, where a v1 subclass that overrides it earns the once-per-subclass hook warning (#280). Don't count the default keys in prose either: this bullet said "three" until #273 added eight typographic pairs in one commit — five Western (`smart_double_quotes`, `low_high_quotes`, `right_double_quotes`, `guillemets`, `reversed_guillemets`) and three CJK — taking it to eleven. (#22) **`TupleManager.__setattr__`/`__delattr__` guard dunder names too, not just `__getattr__`** — constructing a subscripted generic, e.g. `TupleManager[re.Pattern[str] | str]({...})` (needed so mypy sees the right value type instead of inferring one from the dict literal), makes `typing`'s `GenericAlias.__call__` set `__orig_class__` on the new instance right after `__init__` returns. Before this guard existed, `__setattr__` was a bare `dict.__setitem__` alias, so that assignment silently inserted a bogus `'__orig_class__'` entry into the dict itself, corrupting `.values()`/iteration for *every* `TupleManager`/`RegexTupleManager` instance, not just the one being constructed — this bit `nickname_delimiters`'s construction (#22) before the guard was added. Same fix shape as the `__getattr__` dunder guard above: fall back to `object.__setattr__`/`object.__delattr__` for dunder names, dict-backed storage for everything else. diff --git a/docs/customize.rst b/docs/customize.rst index d4815d0d..6b4e08d7 100644 --- a/docs/customize.rst +++ b/docs/customize.rst @@ -29,12 +29,26 @@ above; ``particles``, ``suffix_words``, and the rest work the same way) — see :doc:`modules` for the full field list. The default word lists themselves — ``TITLES``, ``PARTICLES`` and the -rest of ``nameparser.config`` — are frozen, so a runtime addition -belongs on a :class:`~nameparser.Lexicon` as above, or on a private -``Constants`` if you are still parsing through ``HumanName``. Those -constants were renamed in 2.2 to match the field names used here; the -1.x names still import, with a ``DeprecationWarning``, until 3.0 — see -:doc:`migrate` for the mapping. +other frozensets in ``nameparser.config`` — are frozen, so a runtime +addition belongs on a :class:`~nameparser.Lexicon` as above, or on a +private ``Constants`` if you are still parsing through ``HumanName``. +``REGEXES`` and ``CAPITALIZATION_EXCEPTIONS`` are the two members the +freeze does not cover — they are still plain dicts. Editing one at +runtime is not a supported override, and it is not a clean no-op +either: the edit reaches a freshly built ``Constants``, while the +shared ``CONSTANTS`` (copied at import) and the cached +:meth:`~nameparser.Lexicon.default` never see it. That is the same +inconsistent reach the freeze removed for the word lists, so these +overrides belong on a config object too. + +Five of these lists were renamed in 2.2 to match the field names used +here: ``PREFIXES``, ``NON_FIRST_NAME_PREFIXES``, ``BOUND_FIRST_NAMES``, +``FIRST_NAME_TITLES`` and ``SUFFIX_NOT_ACRONYMS`` became ``PARTICLES``, +``NON_GIVEN_NAME_PARTICLES``, ``BOUND_GIVEN_NAMES``, +``GIVEN_NAME_TITLES`` and ``SUFFIX_WORDS``, and two modules moved with +them. Names outside that list, ``TITLES`` among them, are unchanged. +Every 1.x name still imports, with a ``DeprecationWarning``, until 3.0 +— see :doc:`migrate` for the full mapping. Vocabulary entries are matched one word at a time (``given_name_titles`` excepted), so a multi-word entry like ``titles={"grand moff"}`` can diff --git a/nameparser/config/__init__.py b/nameparser/config/__init__.py index d4ce2259..9528f1b9 100644 --- a/nameparser/config/__init__.py +++ b/nameparser/config/__init__.py @@ -19,15 +19,20 @@ compatibility failure. """ # Maintainer note, deliberately outside the docstring: the docstring -# above no longer says "this package is deleted in 3.0", which the -# migration spec's §3 list asserts while enumerating only the shim -# names in its parenthetical. Whether the DATA modules keep this -# package as their home in 3.0 or move under the core is an open -# decision, not something to settle in a published docstring -- and it -# now has a consequence, since Lexicon's public field docs -# cross-reference nameparser.config.particles et al. Resolve it when -# 3.0 is planned; until then this docstring claims only what is -# settled, which is that the re-exports below go. +# above no longer says "this package is deleted in 3.0". The migration +# spec's removal schedule (§4, not §3 -- §3 is the shim itself) does +# say it, listing "nameparser.config in its entirety" while its own +# parenthetical enumerates only the five shim exports re-exported +# below. That gap is the whole reason for this note, so it is stated +# here rather than left to a section number: the spec lives under +# docs/superpowers/, which is gitignored and absent for anyone outside +# this checkout. Whether the DATA modules keep this package as their +# home in 3.0 or move under the core is an open decision, not something +# to settle in a published docstring -- and it now has a consequence, +# since Lexicon's public field docs cross-reference +# nameparser.config.particles et al. Resolve it when 3.0 is planned; +# until then this docstring claims only what is settled, which is that +# the re-exports below go. from nameparser._config_shim import CONSTANTS as CONSTANTS from nameparser._config_shim import Constants as Constants from nameparser._config_shim import RegexTupleManager as RegexTupleManager diff --git a/nameparser/config/suffixes.py b/nameparser/config/suffixes.py index 1e717ce0..f71bdb16 100644 --- a/nameparser/config/suffixes.py +++ b/nameparser/config/suffixes.py @@ -169,11 +169,14 @@ """ SUFFIX_ACRONYMS_AMBIGUOUS = frozenset({ # Suffix acronyms that also commonly work as given-name nicknames on - # their own (e.g. "Ed", "JD"). Read only by HumanName.parse_nicknames() - # when deciding whether parenthesized/quoted content is a nickname or a - # suffix -- content matching one of these stays a nickname rather than - # being reclassified as a suffix, since that's the more common reading - # in ambiguous, delimiter-only context. + # their own (e.g. "Ed", "JD"). Two readers in 2.x, not the single v1 + # one this comment used to name: _extract._suffix_shaped, deciding + # whether parenthesized/quoted content is a nickname or a suffix + # (content matching one of these stays a nickname, the more common + # reading in ambiguous, delimiter-only context), and _vocab's + # suffix_as_written, which excludes the ambiguous subset from plain + # acronym membership so the period gate below is not dead code. + # _classify also tags membership as "vocab:suffix-ambiguous". # # When adding a new entry to SUFFIX_ACRONYMS, also add it here only if # the exact letter sequence could plausibly be someone's name on its @@ -192,7 +195,8 @@ Acronym suffixes from SUFFIX_ACRONYMS that also plausibly collide with a common given-name nickname. Not a partition of SUFFIX_ACRONYMS -- a small, -standalone exception list consulted only by parse_nicknames(). +standalone exception list, read by the delimited-content escape in +``_pipeline/_extract.py`` and by ``_pipeline/_vocab.py``'s period gate. """ SUFFIX_ACRONYMS = frozenset({