nameparser 2.4.2__tar.gz → 2.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. {nameparser-2.4.2/nameparser.egg-info → nameparser-2.5.0}/PKG-INFO +8 -9
  2. {nameparser-2.4.2 → nameparser-2.5.0}/README.rst +7 -8
  3. {nameparser-2.4.2 → nameparser-2.5.0}/nameparser/_config_shim.py +1 -1
  4. {nameparser-2.4.2 → nameparser-2.5.0}/nameparser/_facade.py +5 -5
  5. {nameparser-2.4.2 → nameparser-2.5.0}/nameparser/_lexicon.py +16 -14
  6. {nameparser-2.4.2 → nameparser-2.5.0}/nameparser/_parser.py +7 -7
  7. {nameparser-2.4.2 → nameparser-2.5.0}/nameparser/_pipeline/_assemble.py +24 -5
  8. {nameparser-2.4.2 → nameparser-2.5.0}/nameparser/_pipeline/_assign.py +144 -62
  9. {nameparser-2.4.2 → nameparser-2.5.0}/nameparser/_pipeline/_classify.py +69 -27
  10. {nameparser-2.4.2 → nameparser-2.5.0}/nameparser/_pipeline/_comma.py +187 -51
  11. {nameparser-2.4.2 → nameparser-2.5.0}/nameparser/_pipeline/_extract.py +8 -8
  12. {nameparser-2.4.2 → nameparser-2.5.0}/nameparser/_pipeline/_group.py +87 -53
  13. {nameparser-2.4.2 → nameparser-2.5.0}/nameparser/_pipeline/_pieces.py +121 -77
  14. {nameparser-2.4.2 → nameparser-2.5.0}/nameparser/_pipeline/_post_rules.py +83 -39
  15. {nameparser-2.4.2 → nameparser-2.5.0}/nameparser/_pipeline/_script_segment.py +28 -19
  16. {nameparser-2.4.2 → nameparser-2.5.0}/nameparser/_pipeline/_segment.py +15 -9
  17. {nameparser-2.4.2 → nameparser-2.5.0}/nameparser/_pipeline/_state.py +6 -6
  18. {nameparser-2.4.2 → nameparser-2.5.0}/nameparser/_pipeline/_tokenize.py +8 -8
  19. {nameparser-2.4.2 → nameparser-2.5.0}/nameparser/_pipeline/_vocab.py +122 -51
  20. {nameparser-2.4.2 → nameparser-2.5.0}/nameparser/_policy.py +8 -6
  21. {nameparser-2.4.2 → nameparser-2.5.0}/nameparser/_render.py +53 -29
  22. {nameparser-2.4.2 → nameparser-2.5.0}/nameparser/_types.py +24 -13
  23. {nameparser-2.4.2 → nameparser-2.5.0}/nameparser/_version.py +1 -1
  24. {nameparser-2.4.2 → nameparser-2.5.0}/nameparser/config/__init__.py +1 -1
  25. {nameparser-2.4.2 → nameparser-2.5.0}/nameparser/config/bound_given_names.py +1 -1
  26. {nameparser-2.4.2 → nameparser-2.5.0}/nameparser/config/capitalization.py +5 -5
  27. {nameparser-2.4.2 → nameparser-2.5.0}/nameparser/config/conjunctions.py +5 -3
  28. {nameparser-2.4.2 → nameparser-2.5.0}/nameparser/config/maiden_markers.py +4 -4
  29. {nameparser-2.4.2 → nameparser-2.5.0}/nameparser/config/particles.py +37 -7
  30. {nameparser-2.4.2 → nameparser-2.5.0}/nameparser/config/suffixes.py +129 -18
  31. {nameparser-2.4.2 → nameparser-2.5.0}/nameparser/config/titles.py +18 -11
  32. {nameparser-2.4.2 → nameparser-2.5.0}/nameparser/locales/ja.py +2 -2
  33. {nameparser-2.4.2 → nameparser-2.5.0}/nameparser/locales/zh.py +70 -6
  34. {nameparser-2.4.2 → nameparser-2.5.0/nameparser.egg-info}/PKG-INFO +8 -9
  35. {nameparser-2.4.2 → nameparser-2.5.0}/nameparser.egg-info/SOURCES.txt +7 -0
  36. {nameparser-2.4.2 → nameparser-2.5.0}/tests/test_capitalization.py +16 -16
  37. {nameparser-2.4.2 → nameparser-2.5.0}/tests/test_initials.py +4 -4
  38. {nameparser-2.4.2 → nameparser-2.5.0}/tests/test_suffixes.py +11 -11
  39. {nameparser-2.4.2 → nameparser-2.5.0}/tests/test_titles.py +3 -3
  40. {nameparser-2.4.2 → nameparser-2.5.0}/tests/v2/_differential_fixtures.py +43 -2
  41. {nameparser-2.4.2 → nameparser-2.5.0}/tests/v2/cases.py +1097 -345
  42. nameparser-2.5.0/tests/v2/design_docs.py +107 -0
  43. {nameparser-2.4.2 → nameparser-2.5.0}/tests/v2/pipeline/test_assemble.py +5 -2
  44. {nameparser-2.4.2 → nameparser-2.5.0}/tests/v2/pipeline/test_assign.py +2 -2
  45. {nameparser-2.4.2 → nameparser-2.5.0}/tests/v2/pipeline/test_classify.py +41 -18
  46. {nameparser-2.4.2 → nameparser-2.5.0}/tests/v2/pipeline/test_comma.py +10 -10
  47. {nameparser-2.4.2 → nameparser-2.5.0}/tests/v2/pipeline/test_group.py +35 -29
  48. {nameparser-2.4.2 → nameparser-2.5.0}/tests/v2/pipeline/test_pieces.py +9 -9
  49. {nameparser-2.4.2 → nameparser-2.5.0}/tests/v2/pipeline/test_post_rules.py +21 -19
  50. {nameparser-2.4.2 → nameparser-2.5.0}/tests/v2/pipeline/test_script_segment.py +1 -1
  51. {nameparser-2.4.2 → nameparser-2.5.0}/tests/v2/pipeline/test_segment.py +2 -2
  52. {nameparser-2.4.2 → nameparser-2.5.0}/tests/v2/pipeline/test_vocab.py +5 -5
  53. {nameparser-2.4.2 → nameparser-2.5.0}/tests/v2/rules_doc.py +3 -5
  54. {nameparser-2.4.2 → nameparser-2.5.0}/tests/v2/test_benchmark.py +15 -15
  55. {nameparser-2.4.2 → nameparser-2.5.0}/tests/v2/test_cases.py +23 -6
  56. nameparser-2.5.0/tests/v2/test_cldr_agreement.py +573 -0
  57. {nameparser-2.4.2 → nameparser-2.5.0}/tests/v2/test_corpora.py +16 -6
  58. nameparser-2.5.0/tests/v2/test_corpus_build.py +170 -0
  59. nameparser-2.5.0/tests/v2/test_design_docs.py +150 -0
  60. {nameparser-2.4.2 → nameparser-2.5.0}/tests/v2/test_doc_citations.py +93 -47
  61. {nameparser-2.4.2 → nameparser-2.5.0}/tests/v2/test_doc_spellings.py +1 -1
  62. {nameparser-2.4.2 → nameparser-2.5.0}/tests/v2/test_facade.py +29 -41
  63. {nameparser-2.4.2 → nameparser-2.5.0}/tests/v2/test_facade_cases.py +14 -0
  64. nameparser-2.5.0/tests/v2/test_gnd_corpus.py +301 -0
  65. {nameparser-2.4.2 → nameparser-2.5.0}/tests/v2/test_lexicon.py +4 -4
  66. {nameparser-2.4.2 → nameparser-2.5.0}/tests/v2/test_locales.py +11 -10
  67. nameparser-2.5.0/tests/v2/test_name_usage_tool.py +1156 -0
  68. {nameparser-2.4.2 → nameparser-2.5.0}/tests/v2/test_order_correspondence.py +19 -19
  69. {nameparser-2.4.2 → nameparser-2.5.0}/tests/v2/test_parser.py +88 -41
  70. {nameparser-2.4.2 → nameparser-2.5.0}/tests/v2/test_policy.py +4 -3
  71. {nameparser-2.4.2 → nameparser-2.5.0}/tests/v2/test_properties.py +154 -46
  72. {nameparser-2.4.2 → nameparser-2.5.0}/tests/v2/test_regex_sync.py +6 -1
  73. {nameparser-2.4.2 → nameparser-2.5.0}/tests/v2/test_render.py +82 -42
  74. {nameparser-2.4.2 → nameparser-2.5.0}/tests/v2/test_rules_doc.py +7 -8
  75. {nameparser-2.4.2 → nameparser-2.5.0}/tests/v2/test_rules_doc_grammar.py +4 -4
  76. {nameparser-2.4.2 → nameparser-2.5.0}/tests/v2/test_snapshot.py +1 -1
  77. nameparser-2.5.0/tests/v2/test_wikidata_corpus.py +429 -0
  78. nameparser-2.5.0/tests/v2/test_wikidata_native_corpus.py +480 -0
  79. nameparser-2.5.0/tests/v2/test_zh_surnames_tool.py +145 -0
  80. nameparser-2.4.2/tests/v2/test_cldr_agreement.py +0 -277
  81. nameparser-2.4.2/tests/v2/test_name_usage_tool.py +0 -303
  82. {nameparser-2.4.2 → nameparser-2.5.0}/AUTHORS +0 -0
  83. {nameparser-2.4.2 → nameparser-2.5.0}/LICENSE +0 -0
  84. {nameparser-2.4.2 → nameparser-2.5.0}/MANIFEST.in +0 -0
  85. {nameparser-2.4.2 → nameparser-2.5.0}/nameparser/__init__.py +0 -0
  86. {nameparser-2.4.2 → nameparser-2.5.0}/nameparser/__main__.py +0 -0
  87. {nameparser-2.4.2 → nameparser-2.5.0}/nameparser/_locale.py +0 -0
  88. {nameparser-2.4.2 → nameparser-2.5.0}/nameparser/_pipeline/__init__.py +0 -0
  89. {nameparser-2.4.2 → nameparser-2.5.0}/nameparser/config/_deprecated.py +0 -0
  90. {nameparser-2.4.2 → nameparser-2.5.0}/nameparser/config/_invariants.py +0 -0
  91. {nameparser-2.4.2 → nameparser-2.5.0}/nameparser/config/bound_first_names.py +0 -0
  92. {nameparser-2.4.2 → nameparser-2.5.0}/nameparser/config/prefixes.py +0 -0
  93. {nameparser-2.4.2 → nameparser-2.5.0}/nameparser/config/regexes.py +0 -0
  94. {nameparser-2.4.2 → nameparser-2.5.0}/nameparser/config/surnames.py +0 -0
  95. {nameparser-2.4.2 → nameparser-2.5.0}/nameparser/locales/__init__.py +0 -0
  96. {nameparser-2.4.2 → nameparser-2.5.0}/nameparser/locales/ru.py +0 -0
  97. {nameparser-2.4.2 → nameparser-2.5.0}/nameparser/locales/tr_az.py +0 -0
  98. {nameparser-2.4.2 → nameparser-2.5.0}/nameparser/parser.py +0 -0
  99. {nameparser-2.4.2 → nameparser-2.5.0}/nameparser/py.typed +0 -0
  100. {nameparser-2.4.2 → nameparser-2.5.0}/nameparser/util.py +0 -0
  101. {nameparser-2.4.2 → nameparser-2.5.0}/nameparser.egg-info/dependency_links.txt +0 -0
  102. {nameparser-2.4.2 → nameparser-2.5.0}/nameparser.egg-info/requires.txt +0 -0
  103. {nameparser-2.4.2 → nameparser-2.5.0}/nameparser.egg-info/top_level.txt +0 -0
  104. {nameparser-2.4.2 → nameparser-2.5.0}/pyproject.toml +0 -0
  105. {nameparser-2.4.2 → nameparser-2.5.0}/setup.cfg +0 -0
  106. {nameparser-2.4.2 → nameparser-2.5.0}/tests/__init__.py +0 -0
  107. {nameparser-2.4.2 → nameparser-2.5.0}/tests/base.py +0 -0
  108. {nameparser-2.4.2 → nameparser-2.5.0}/tests/conftest.py +0 -0
  109. {nameparser-2.4.2 → nameparser-2.5.0}/tests/test_bound_given_names.py +0 -0
  110. {nameparser-2.4.2 → nameparser-2.5.0}/tests/test_brute_force.py +0 -0
  111. {nameparser-2.4.2 → nameparser-2.5.0}/tests/test_comma_variants.py +0 -0
  112. {nameparser-2.4.2 → nameparser-2.5.0}/tests/test_conjunctions.py +0 -0
  113. {nameparser-2.4.2 → nameparser-2.5.0}/tests/test_constants.py +0 -0
  114. {nameparser-2.4.2 → nameparser-2.5.0}/tests/test_east_slavic_patronymic_order.py +0 -0
  115. {nameparser-2.4.2 → nameparser-2.5.0}/tests/test_first_name.py +0 -0
  116. {nameparser-2.4.2 → nameparser-2.5.0}/tests/test_middle_name_as_last.py +0 -0
  117. {nameparser-2.4.2 → nameparser-2.5.0}/tests/test_nicknames.py +0 -0
  118. {nameparser-2.4.2 → nameparser-2.5.0}/tests/test_output_format.py +0 -0
  119. {nameparser-2.4.2 → nameparser-2.5.0}/tests/test_particles.py +0 -0
  120. {nameparser-2.4.2 → nameparser-2.5.0}/tests/test_python_api.py +0 -0
  121. {nameparser-2.4.2 → nameparser-2.5.0}/tests/test_turkic_patronymic_order.py +0 -0
  122. {nameparser-2.4.2 → nameparser-2.5.0}/tests/test_variations.py +0 -0
  123. {nameparser-2.4.2 → nameparser-2.5.0}/tests/v2/__init__.py +0 -0
  124. {nameparser-2.4.2 → nameparser-2.5.0}/tests/v2/conftest.py +0 -0
  125. {nameparser-2.4.2 → nameparser-2.5.0}/tests/v2/pipeline/__init__.py +0 -0
  126. {nameparser-2.4.2 → nameparser-2.5.0}/tests/v2/pipeline/test_extract.py +0 -0
  127. {nameparser-2.4.2 → nameparser-2.5.0}/tests/v2/pipeline/test_state.py +0 -0
  128. {nameparser-2.4.2 → nameparser-2.5.0}/tests/v2/pipeline/test_tokenize.py +0 -0
  129. {nameparser-2.4.2 → nameparser-2.5.0}/tests/v2/test_cldr_corpus.py +0 -0
  130. {nameparser-2.4.2 → nameparser-2.5.0}/tests/v2/test_cli.py +0 -0
  131. {nameparser-2.4.2 → nameparser-2.5.0}/tests/v2/test_config_aliases.py +0 -0
  132. {nameparser-2.4.2 → nameparser-2.5.0}/tests/v2/test_config_shim.py +0 -0
  133. {nameparser-2.4.2 → nameparser-2.5.0}/tests/v2/test_contracts.py +0 -0
  134. {nameparser-2.4.2 → nameparser-2.5.0}/tests/v2/test_differential.py +0 -0
  135. {nameparser-2.4.2 → nameparser-2.5.0}/tests/v2/test_layering.py +0 -0
  136. {nameparser-2.4.2 → nameparser-2.5.0}/tests/v2/test_locale.py +0 -0
  137. {nameparser-2.4.2 → nameparser-2.5.0}/tests/v2/test_reprs.py +0 -0
  138. {nameparser-2.4.2 → nameparser-2.5.0}/tests/v2/test_types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nameparser
3
- Version: 2.4.2
3
+ Version: 2.5.0
4
4
  Summary: A simple Python module for parsing human names into their individual components.
5
5
  Author-email: Derek Gulbranson <derek73@gmail.com>
6
6
  License: LGPL
@@ -36,14 +36,13 @@ nameparser parses human names into seven fields — title, given, middle,
36
36
  family, suffix, nickname, maiden. Results are immutable, configuration is
37
37
  composable, and locale packs are opt-in.
38
38
 
39
- 📣 **nameparser 2.4 is out.** It uses letter case to read post-nominal
40
- credentials much more reliably — ``Jack MA`` is Jack with an MA and
41
- ``Jack Ma`` is the surname Ma, and ``Jane Smith, PhD MA`` keeps both
42
- credentials together — recognizes more naming conventions (Catalan,
43
- Irish, Finnish, Malay and others), and writes credentials the usual way
44
- when it repairs case. The `release log
45
- <https://nameparser.readthedocs.io/en/latest/release_log.html>`__ has the
46
- details.
39
+ 📣 **nameparser 2.5 is out.** It reads junior and senior written in 45
40
+ languages (``Antonio Pavlović mlađi``), keeps a title written before a
41
+ family comma out of the surname (``Dr. Smith, John``), reads Chinese and
42
+ Japanese credential and office words (``田中社長``), and gives a lone first
43
+ name followed by V, I or X its last name back (``Malcolm X``). The `release
44
+ log <https://nameparser.readthedocs.io/en/latest/release_log.html>`__ has
45
+ the details.
47
46
 
48
47
  Chinese, Japanese and Korean names written in their own scripts are read
49
48
  family-first, unspaced Korean names are split against the census surname
@@ -7,14 +7,13 @@ nameparser parses human names into seven fields — title, given, middle,
7
7
  family, suffix, nickname, maiden. Results are immutable, configuration is
8
8
  composable, and locale packs are opt-in.
9
9
 
10
- 📣 **nameparser 2.4 is out.** It uses letter case to read post-nominal
11
- credentials much more reliably — ``Jack MA`` is Jack with an MA and
12
- ``Jack Ma`` is the surname Ma, and ``Jane Smith, PhD MA`` keeps both
13
- credentials together — recognizes more naming conventions (Catalan,
14
- Irish, Finnish, Malay and others), and writes credentials the usual way
15
- when it repairs case. The `release log
16
- <https://nameparser.readthedocs.io/en/latest/release_log.html>`__ has the
17
- details.
10
+ 📣 **nameparser 2.5 is out.** It reads junior and senior written in 45
11
+ languages (``Antonio Pavlović mlađi``), keeps a title written before a
12
+ family comma out of the surname (``Dr. Smith, John``), reads Chinese and
13
+ Japanese credential and office words (``田中社長``), and gives a lone first
14
+ name followed by V, I or X its last name back (``Malcolm X``). The `release
15
+ log <https://nameparser.readthedocs.io/en/latest/release_log.html>`__ has
16
+ the details.
18
17
 
19
18
  Chinese, Japanese and Korean names written in their own scripts are read
20
19
  family-first, unspaced Korean names are split against the census surname
@@ -1109,7 +1109,7 @@ class Constants:
1109
1109
  # tests/v2/test_config_shim.py::test_snapshot_field_translation)
1110
1110
  # A capitalization_exceptions value that does not spell its key
1111
1111
  # is the one DECIDED exception to this method's never-raise
1112
- # rule (#459, decisions.md#R4 and #3-0-reevaluations).
1112
+ # rule (#459, decisions/R4 and #3-0-reevaluations).
1113
1113
  # _normpairs raises _MaskValueError for exactly that shape, and
1114
1114
  # the except clause re-spells its offered fix for a v1 caller.
1115
1115
  # Caught by TYPE, so every other Lexicon error passes through
@@ -271,7 +271,7 @@ class HumanName:
271
271
  """Re-capitalize the current parse against the bound lexicon.
272
272
  force=None reads the bound Constants' render default
273
273
  (force_mixed_case_capitalization); the core's capitalized()
274
- implements the one-case gate (rules.md#R5, suffixes left out
274
+ implements the one-case gate (rules/R5, suffixes left out
275
275
  since #492) -- not
276
276
  re-implemented here."""
277
277
  self._resolve()
@@ -511,7 +511,7 @@ class HumanName:
511
511
  # answers -- the same fallback _render._cap_word takes for the
512
512
  # same tokens and through the same helper, which is why the
513
513
  # helper is imported rather than the predicate rewritten
514
- # (decisions.md#R4 for why one question and not two: whether a
514
+ # (decisions/R4 for why one question and not two: whether a
515
515
  # word is a connective is a fact the word can answer alone,
516
516
  # whether a particle is acting as one is a fact about the part).
517
517
  #
@@ -533,7 +533,7 @@ class HumanName:
533
533
  and UNJOINED_CONJUNCTION_TAG not in tok.tags)
534
534
 
535
535
  def _split_last(self) -> tuple[list[str], list[str]]:
536
- # rules.md#R2: "a name part whose every word is particle
536
+ # rules/R2: "a name part whose every word is particle
537
537
  # vocabulary is a part where none of them is doing a
538
538
  # particle's work" -- the all-particle guard
539
539
  # below is this rule, and predates its statement: v1 assumed a
@@ -617,7 +617,7 @@ class HumanName:
617
617
  #
618
618
  # Particles are NOT decided per token: _is_particle stays a
619
619
  # live vocabulary lookup, as _render._cap_word keeps it --
620
- # rules.md#R4 draws this boundary per question, not per field.
620
+ # rules/R4 draws this boundary per question, not per field.
621
621
  self._resolve()
622
622
  if tokens is None:
623
623
  # STRING PATH. split() rather than split(" ") because
@@ -700,7 +700,7 @@ class HumanName:
700
700
  if got or not words or not all(self._is_particle(w)
701
701
  for w in words):
702
702
  return got
703
- # rules.md#R3: "except the particles of a part whose every
703
+ # rules/R3: "except the particles of a part whose every
704
704
  # word is one, which are not acting as particles there"
705
705
  # -- nothing survived
706
706
  # the filter, so the whole group is particles. The
@@ -129,7 +129,7 @@ _PHRASE_FIELDS = ("given_name_titles", "maiden_markers")
129
129
  #: it is roman five -- suffix -- where 'V.' stays a middle
130
130
  #: initial through that same carve-out; 'Jr。' simply reaches its
131
131
  #: entry, and 'Dr。' its title.
132
- #: decisions.md#cjk-full-stops carries the measurements.
132
+ #: decisions/cjk-full-stops carries the measurements.
133
133
  FULL_STOPS = "..。。"
134
134
 
135
135
 
@@ -152,7 +152,7 @@ def _normalize(word: str) -> str:
152
152
 
153
153
  NFC, since #322, and only for a non-ASCII word (ASCII is already
154
154
  NFC, and this runs per token on the parse path --
155
- decisions.md#parse-cost). NFD hangul decomposes to jamo and missed
155
+ decisions/parse-cost). NFD hangul decomposes to jamo and missed
156
156
  every vocabulary entry; composing at lookup AND at storage keeps
157
157
  the two symmetric, and no shipped entry changes under it (measured
158
158
  2026-09-09: fold every string member of every Lexicon.default()
@@ -175,7 +175,7 @@ def _normalize(word: str) -> str:
175
175
  word = stripped
176
176
 
177
177
 
178
- # rules.md#P7: "A one-letter word written with its period is an
178
+ # rules/P7: "A one-letter word written with its period is an
179
179
  # initial and not a particle"
180
180
  def _spells_an_initial(n: str, text: str) -> bool:
181
181
  """Whether `text` is ONE letter written with its period ('Ó.',
@@ -195,7 +195,7 @@ def _spells_an_initial(n: str, text: str) -> bool:
195
195
  -- its script table is _policy's, which this module may not import
196
196
  -- so a caller's one-character particle in such a script, written
197
197
  with a period, is vetoed where is_initial would call it no initial
198
- (decisions.md#P7). No shipped particle is one character of those
198
+ (decisions/P7). No shipped particle is one character of those
199
199
  scripts."""
200
200
  return len(n) == 1 and text[-1] == "."
201
201
 
@@ -224,7 +224,7 @@ def _fold_words(words: Iterable[str]) -> list[str]:
224
224
 
225
225
  map/filter rather than a comprehension: both are C calls where a
226
226
  comprehension is a Python frame on 3.11, and this runs on the parse
227
- path for every name carrying a title run (decisions.md#parse-cost).
227
+ path for every name carrying a title run (decisions/parse-cost).
228
228
  """
229
229
  return list(filter(None, map(_normalize, words)))
230
230
 
@@ -247,7 +247,7 @@ def _run_addresses_by_given(words: Iterable[str],
247
247
 
248
248
  Two sites ask it and they must agree -- post_rules for H1, group
249
249
  for the P5 licence -- because a run read two ways is a rule
250
- contradicting itself (decisions.md#P5, the 2026-08-22 #369 entry).
250
+ contradicting itself (decisions/P5, the 2026-08-22 #369 entry).
251
251
  One predicate, so they cannot drift.
252
252
 
253
253
  The WHOLE run's key, or that key's LAST word (#489): a run is
@@ -542,7 +542,7 @@ def _normpairs(
542
542
  # punctuation marks which letters are joined into one run
543
543
  # (_render._apply_mask reads that) and is never written into
544
544
  # the word. A raise, not a warning: a mismatched value used to
545
- # be SUBSTITUTED for the word, and rules.md#R4: "Repair
545
+ # be SUBSTITUTED for the word, and rules/R4: "Repair
546
546
  # changes case and nothing else", so there is no reading of
547
547
  # one that repair can honor.
548
548
  if _alnum(_normalize(v)) != _alnum(normalized_key):
@@ -582,8 +582,8 @@ class Lexicon:
582
582
  #: Pre-nominal titles ("dr", "sir", "capt", ...). Full default
583
583
  #: list: :data:`~nameparser.config.titles.TITLES`.
584
584
  titles: frozenset[str] = frozenset()
585
- #: Titles whose single following name reads as a GIVEN name
586
- #: ("sheikh", "sister", ...) rather than a family name. Full
585
+ #: Titles whose single name word, before or after them, reads as a
586
+ #: GIVEN name ("sheikh", "sister", ...) rather than a family name. Full
587
587
  #: default list: :data:`~nameparser.config.titles.GIVEN_NAME_TITLES`.
588
588
  given_name_titles: frozenset[str] = frozenset()
589
589
  #: Post-nominal acronym suffixes, matched with or without periods
@@ -599,7 +599,7 @@ class Lexicon:
599
599
  #: outright; written bare, capitals decide in a mixed-case name
600
600
  #: ("Jack MA" is the credential, "Jack Ma" keeps his family name)
601
601
  #: and otherwise the word counts as a suffix only where a name
602
- #: word is left beside it ("John Smith ma"), rules.md#S2. Full
602
+ #: word is left beside it ("John Smith ma"), rules/S6 and rules/S4. Full
603
603
  #: default list:
604
604
  #: :data:`~nameparser.config.suffixes.SUFFIX_ACRONYMS_AMBIGUOUS`.
605
605
  suffix_acronyms_ambiguous: frozenset[str] = frozenset()
@@ -644,8 +644,10 @@ class Lexicon:
644
644
  #: decided by the writing instead and never consults this set, and
645
645
  #: neither does a caseless letter (Arabic و), which has no case to
646
646
  #: read. A member additionally reports
647
- #: :attr:`~nameparser.AmbiguityKind.CONJUNCTION_OR_INITIAL`; a
648
- #: non-member reports nothing, its reading not being in doubt.
647
+ #: :attr:`~nameparser.AmbiguityKind.CONJUNCTION_OR_INITIAL` where a
648
+ #: name word of its part follows it (with none, it connects
649
+ #: nothing); a non-member reports nothing, its reading not being in
650
+ #: doubt.
649
651
  #: Full default list:
650
652
  #: :data:`~nameparser.config.conjunctions.CONJUNCTIONS_AMBIGUOUS`.
651
653
  #: Entries need not also be in ``conjunctions``; one that is not is
@@ -673,8 +675,8 @@ class Lexicon:
673
675
  #: (:data:`~nameparser.config.surnames.KOREAN_SURNAMES`); Chinese
674
676
  #: surnames ship in locales.ZH because Han segmentation is opt-in.
675
677
  surnames: frozenset[str] = frozenset()
676
- #: Honorifics that may be peeled off the END of a name token
677
- #: (#308), matched longest-first: 田中さん splits into 田中 and さん
678
+ #: Honorifics, and credential, office and generational words, that
679
+ #: may be peeled off the END of a name token (#308, #707), matched longest-first: 田中さん splits into 田中 and さん
678
680
  #: before the tokens are classified. Every entry must also be a
679
681
  #: :attr:`suffix_words` entry -- the peeled tail is claimed by
680
682
  #: suffix classification like any other post-nominal. Deliberately
@@ -41,7 +41,7 @@ class Parser:
41
41
  vocabulary DECLINES, so a locale pack's surnames always win where
42
42
  they match; returning None declines in turn and the token stays
43
43
  whole. Two promises narrow when one is supplied (the first is
44
- rules.md#A1's Accepted clause):
44
+ rules/A1's Accepted clause):
45
45
  parse-totality gains its one exception -- an exception raised by
46
46
  the segmenter propagates, because a user-supplied callable's own
47
47
  error is a user-code error, not a content error -- and this Parser
@@ -220,7 +220,7 @@ class Parser:
220
220
  # tags are KEPT, "joined" included: a within-piece mark
221
221
  # (the Ph. D. merge) is role-blind and right for every
222
222
  # role, and a clear was measured and backed out --
223
- # decisions.md#C1 (2026-09-06 #511) carries the rest: the
223
+ # decisions/R1 (2026-09-06 #511) carries the rest: the
224
224
  # between-piece mark that rides onto a non-suffix role, and
225
225
  # which views read it. Dropped tokens keep their role: the
226
226
  # pass filters them by index and assemble omits them.
@@ -228,7 +228,7 @@ class Parser:
228
228
  tok if i in dropped
229
229
  else dataclasses.replace(tok, role=role)
230
230
  for i, tok in enumerate(state.tokens))
231
- # rules.md#R1: "a run of post-nominals written with spaces
231
+ # rules/R1: "a run of post-nominals written with spaces
232
232
  # renders with spaces, and one written with commas keeps
233
233
  # them" -- the same pass post_rules runs last, over the
234
234
  # value's own comma offsets, so the harvest carries exactly
@@ -346,13 +346,13 @@ def parser_for(*locales: Locale, base: Parser | None = None,
346
346
  # a subclass with extra mandatory args would break this rewrap
347
347
  raise type(exc)(
348
348
  f"while applying locale {loc.code!r}: {exc}") from exc
349
- # rules.md#D1: "constructing a parser that activates division for
349
+ # rules/D1: "constructing a parser that activates division for
350
350
  # scripts with no covering surnames and no segmenter warns at
351
351
  # construction, naming the dead scripts and each way out"
352
- # (history: decisions.md#D1)
353
- # rules.md#D2: "applying a locale pack wraps any such error with
352
+ # (history: decisions/D1)
353
+ # rules/D2: "applying a locale pack wraps any such error with
354
354
  # the locale's code, so a stacked configuration names which layer
355
- # broke" (history: decisions.md#D2)
355
+ # broke" (history: decisions/D2)
356
356
  # Construction warnings (the segmenterless-activation check in
357
357
  # Parser.__post_init__) re-emit from THIS frame: its stacklevel is
358
358
  # sized for direct Parser(...) construction, and through this
@@ -2,7 +2,8 @@
2
2
 
3
3
  Consumes: tokens (all roles set), dropped, ambiguities (by index;
4
4
  one carrying ``field_tail`` is worded from its word's final role,
5
- #626).
5
+ #626), and segments, for the part a withdrawn connective report's
6
+ letter ends (rules/P3, #721).
6
7
  Produces: a validated ParsedName -- the constructor re-checks every
7
8
  invariant (span order/bounds, ambiguity subset), so a pipeline bug
8
9
  that would produce an invalid result dies HERE, not in a renderer
@@ -20,16 +21,16 @@ tests/v2/pipeline/test_assemble.py.
20
21
  """
21
22
  from __future__ import annotations
22
23
 
23
- from nameparser._pipeline._state import FIELD_READING, ParseState
24
+ from nameparser._pipeline._state import FIELD_READING, NAME_ROLES, ParseState
24
25
  from nameparser._types import (
25
26
  Ambiguity, AmbiguityKind, ParsedName, Role, Token,
26
27
  )
27
28
 
28
29
 
29
- # rules.md#A2: "a name with no name content parses to the empty
30
+ # rules/A2: "a name with no name content parses to the empty
30
31
  # name — every field empty, false as a boolean — while ambiguities
31
32
  # born from its punctuation survive on the empty result"
32
- # (history: decisions.md#A2)
33
+ # (history: decisions/A2)
33
34
  def assemble(state: ParseState) -> ParsedName:
34
35
  dropped = set(state.dropped)
35
36
  final: dict[int, Token] = {}
@@ -74,6 +75,7 @@ def assemble(state: ParseState) -> ParsedName:
74
75
  if contentless:
75
76
  final = {}
76
77
  ambiguities = []
78
+ segment_of: dict[int, tuple[int, ...]] | None = None
77
79
  for pending in state.ambiguities:
78
80
  materialized = tuple(final[i] for i in pending.indices
79
81
  if i in final)
@@ -85,7 +87,7 @@ def assemble(state: ParseState) -> ParsedName:
85
87
  # referents did not lose a contest, they were discarded
86
88
  # wholesale, and the report still describes the input.
87
89
  continue
88
- # rules.md#A1: "a report names the reading the parse took, so
90
+ # rules/A1: "a report names the reading the parse took, so
89
91
  # a report whose fork the rest of the parse then resolved to
90
92
  # NEITHER branch is withdrawn rather than carried beside a
91
93
  # reading it contradicts". classify's connective-or-initial
@@ -114,6 +116,23 @@ def assemble(state: ParseState) -> ParsedName:
114
116
  and any(t.role in (Role.SUFFIX, Role.TITLE)
115
117
  for t in materialized)):
116
118
  continue
119
+ # rules/P3: "unless no name word of its part stands to its
120
+ # right" -- nothing after the letter in its segment, or only
121
+ # suffixes, titles, a maiden clause or a nickname: it connects
122
+ # nothing, so the report goes (#721). Asked here for the reason
123
+ # above: the roles are final. Where the letter is also a
124
+ # numeral the fork read, its own report (rules/S10) is the one
125
+ # left, so one token never carries both. Built only for a
126
+ # connective report, so other names pay nothing.
127
+ if pending.kind is AmbiguityKind.CONJUNCTION_OR_INITIAL:
128
+ if segment_of is None:
129
+ segment_of = {j: seg for seg in state.segments
130
+ for j in seg}
131
+ i = pending.indices[0]
132
+ if not any(j > i and j in final
133
+ and final[j].role in NAME_ROLES
134
+ for j in segment_of.get(i, ())):
135
+ continue
117
136
  detail = pending.detail
118
137
  if pending.field_tail is not None:
119
138
  # A report naming a field, in a name emptied above (no