1"""Not a stage: rules/C1's decision, made by group at its head.
2
3The comma's structure is decided ONCE, after classify has tagged every
4word and before group joins any of them (#613): the part after the
5first comma is read by `_pieces.segment_suffix_reading`, the one
6reading a part that holds no name word gets, with the words C4's count
7licenses as credentials behind a whole name. Where it reads as the
8postnominal part its roles are set here, so no join in group and no
9walk in assign can reach them, and the comma is a suffix comma when
10the part before it is a whole name; otherwise the family comma
11`segment` handed over stands, and assign reads the listing form.
12
13Deciding in `segment`, before the tags existed, meant predicting what
14later stages would do -- group's particle chain (#562's pair), assign's
15reading of a run the capitals settle -- and assign re-deciding #296's
16positional read with a second count; reading once and binding is
17mechanisms.md#READ-WITHOUT-THEN-BIND.
18
19Reads: Lexicon suffix, title, particle and ambiguous-class vocabulary
20through the tags classify wrote and through _vocab.name_word_count and
21_vocab.is_wholly_suffix; every Lexicon wordlist through
22_vocab.caps_shape_candidate (to know an all-caps word is unlisted) and
23_vocab.claimed_as_non_name (the name's contrast); Lexicon
24.maiden_markers through _pieces.own_words, for the name's contrast;
25Policy.lenient_comma_suffixes, Policy.extra_suffix_delimiters,
26Policy.unlisted_caps_suffixes and Policy.unlisted_dotted_suffixes.
27"""
28from __future__ import annotations
29
30import unicodedata
31from collections.abc import Sequence, Set
32
33from nameparser._lexicon import Lexicon, _normalize
34from nameparser._pipeline._pieces import (
35 split_credential_report,
36 has_name_content, is_suffix_piece, is_title_piece, join_connectives,
37 listed_lean, own_words, segment_suffix_reading, settled_by_case,
38)
39from nameparser._pipeline._state import (
40 AMBIGUOUS_ACRONYM_TAG, AMBIGUOUS_WORD_TAG, GENERATION_WORD_TAG,
41 SHAPE_ACRONYM_TAG,
42 SPLIT_CREDENTIAL_TAG, SPLIT_MEMBER_TAG, ParseState, PendingAmbiguity,
43 PendingReading, Structure, WorkToken, copy_with,
44)
45from nameparser._pipeline._vocab import (
46 ROMAN, caps_shape_candidate, claimed_as_non_name,
47 composed_len, delimiter_cores, ends_in_a_generation, glued_cores,
48 glued_stem, glues, is_caps_word,
49 is_letter_run, is_paired_initials, is_single_letter_numeral,
50 is_wholly_suffix, _joined_chunks, _listed_whole,
51 name_reading, name_word_count, reads_as_generation, suffix_reading,
52 surname_unit_facts, unit_ends, written_as_a_name,
53)
54from nameparser._policy import CapsSuffixes
55from nameparser._types import AmbiguityKind, Role
56
57
58def _pieces(seg: Sequence[int], tokens: Sequence[WorkToken],
59 splits: bool = True,
60 ) -> tuple[list[list[int]], list[set[str]]]:
61 """One piece per token but a split credential (`split_pair`, read
62 off the tag classify wrote on its first word), which group's first
63 merge builds (group alone declines a pair at the head of the
64 string, rules/S2) -- the part as it stands before any join: the
65 Ph./D. pair tagged "suffix" where the lexicon lists `phd` as
66 unambiguous suffix vocabulary, any other "split", a member of the
67 ambiguous class for rules/C4's count, unless `splits` is False.
68 Every piece a fresh list, as `join_connectives` requires: it
69 extends a joined piece's first list in place."""
70 pieces: list[list[int]] = []
71 ptags: list[set[str]] = []
72 k = 0
73 while k < len(seg):
74 a = seg[k]
75 tags = tokens[a].tags
76 if k + 1 < len(seg) and (SPLIT_CREDENTIAL_TAG in tags
77 or splits and SPLIT_MEMBER_TAG in tags):
78 pieces.append([a, seg[k + 1]])
79 ptags.append({"suffix" if SPLIT_CREDENTIAL_TAG in tags
80 else "split"})
81 k += 2
82 else:
83 pieces.append([a])
84 ptags.append(set())
85 k += 1
86 return pieces, ptags
87
88
89def _reading_pieces(part: Sequence[int], tokens: Sequence[WorkToken],
90 splits: bool = True,
91 ) -> tuple[list[list[int]], list[set[str]]]:
92 """The part as the reading takes it: `_pieces`, then the one join it
93 can hold."""
94 pieces, ptags = _pieces(part, tokens, splits)
95 # The one join a postnominal part can hold, made here so the reading
96 # sees it: rules/P3's connective joining its neighbours where the
97 # neighbour it takes its kind from is a title, which makes the
98 # joined part a title ('Mr. and Mrs.', 'Secretary of State', 'Dr.
99 # and'), a run of connectives merging first as it does in the name
100 # ('Minister of the Interior'). A LONE single-letter connective is
101 # left as it stands, P3 or P8 reading it as a name word or an initial in
102 # short names; one beside another connective is a member of their
103 # run and joins with it ('Mr. y and Mrs.'). Any other join is not
104 # made here: where no credential opens the part, a name word in it
105 # makes it the given part; where one does, the run #603 opens takes
106 # the connective as a word and reports it ('John Smith, PhD and
107 # Dr.'). Group's own joins, the one loop since #617: its copy here
108 # merged no run and joined 'Minister of the' alone.
109 #
110 # rules/P3: "A connective that is also generational vocabulary
111 # joins only where a name word stands on each side of it" -- and in
112 # a part read for credentials none does, so every such connective
113 # is frozen here, where group freezes only the ones it finds without
114 # a name word beside them ('Rovira, Dr. i i' keeps suffix 'i i'
115 # rather than run-merging the pair into the title, #617); a
116 # neighbour's join may still take one in ('Smith, Dr. and i' reads
117 # title 'Dr. and i'), as `join_connectives` says.
118 #
119 # The same walk says whether there is anything to join, and a part
120 # with no connective left free -- nearly every part -- skips the
121 # shared loop, whose predicate calls cost two frames per word
122 # ('Smith, John' paid two; #617's review).
123 frozen: set[int] = set()
124 free = False
125 for i in part:
126 tags = tokens[i].tags
127 if "conjunction" in tags:
128 if "vocab:suffix" in tags:
129 frozen.add(i)
130 else:
131 free = True
132 if free:
133 join_connectives(pieces, ptags, tokens, letter_stays=True,
134 titles_only=True, frozen=frozen)
135 return pieces, ptags
136
137
138def _unclaimed_name_word(text: str, lexicon: Lexicon) -> bool:
139 """A word written as a name and claimed by no wordlist as anything
140 else -- #564's contrast word and rules/C12's veto, one question.
141 The caller tests for a period first, in C: a word with one is an
142 abbreviation or an initial, not a name word."""
143 return (written_as_a_name(text)
144 and not claimed_as_non_name(_normalize(text), lexicon))
145
146
147def _name_contrast(state: ParseState, seg0: Sequence[int],
148 clause_at: int | None = None) -> bool:
149 """#564 (Derek): the contrast the caps shape needs is the NAME's,
150 and the name carries it only if one of its own words before the
151 comma (no maiden clause, no delimited content, as `own_words`
152 defines them) is written the way a name is written in mixed case
153 -- a capital in it and its last letter lowercase
154 (`written_as_a_name`) -- and is not claimed as a title, particle,
155 connective, credential or generation (`claimed_as_non_name`); a
156 word with a period is an abbreviation or an initial, not one. A
157 lone capital ('de GAULLE C'), a lowercase-only word and a surname
158 written in capitals with anything glued in front ("d'ESTAING",
159 'FitzGERALD', 'McDONALD') all fail it, so such a record keeps its
160 given name beside its lowercase particles, clauses and titles and
161 beside a mixed-case credential ('LLOYD WEBBER, ANDREW PhD'). A part
162 with no lowercase at all is settled in one C-level comparison.
163 `clause_at` is `own_words`' clause index where the caller already
164 holds it."""
165 before = "".join([state.tokens[i].text for i in seg0])
166 if before == before.upper():
167 return False
168 if clause_at is None:
169 _, clause_at = own_words(state.tokens, state.comma_offsets,
170 state.lexicon._sets.maiden_markers)
171 lex = state.lexicon
172 return any(
173 i < clause_at and (tok := state.tokens[i]).role is None
174 and "." not in tok.text and _unclaimed_name_word(tok.text, lex)
175 for i in seg0)
176
177
178def _whole_name(seg0: Sequence[int], tokens: Sequence[WorkToken],
179 one_case: bool | None) -> bool:
180 """rules/C3's count before the comma for an unambiguous
181 credential, read off classify's tags (the ambiguous class's count
182 of NAME words, which leaves titles out, is
183 `_vocab.name_word_count`'s, asked in `decide`): two or more units (a particle run and
184 the name word it attaches to one unit, a connective join not, #575)
185 holding a word that is not a suffix word -- a title counts, being a
186 word to the count an unambiguous credential takes, and so does a
187 suffix word opening the part with a name word behind it. A numeral
188 ending the part, or standing before the suffix words that end it,
189 that the trailing fork reads as the generation is no name word, as
190 a listed one is not (`one_case` is
191 ParseState.one_case, for that fork)."""
192 # rules/C3: "a particle run and the one name word it attaches to are
193 # one word"
194 # the part before a family comma merges no member pair (rules/S12)
195 pieces, ptags = _pieces(seg0, tokens, False)
196 named: set[int] = set()
197 opener: list[int] = []
198 name_behind = False
199 for k, (piece, tags) in enumerate(zip(pieces, ptags)):
200 if not is_suffix_piece(piece, tags, tokens):
201 named.update(piece)
202 # a title behind it is a word to this count but no name
203 # word, asked only once a suffix word has opened the part
204 if opener and not is_title_piece(piece, tags, tokens):
205 name_behind = True
206 elif k == 0 and SPLIT_MEMBER_TAG not in tokens[piece[0]].tags:
207 opener = piece
208 # rules/C3: "A suffix word opening the part is a name word to the
209 # count where a name word follows it" -- a split credential's
210 # member is half a credential, not a name (rules/S12)
211 if name_behind:
212 named.update(opener)
213 # rules/C3: "a numeral ending the part, or standing before the
214 # listed suffix words it ends with, that the trailing fork reads as the
215 # generation is no name word" ('Dumas VI, Jr.' and 'Dumas VI MD,
216 # Ed' as their 'III' twins); decisions/C3 (#893). The suffix
217 # words are the pieces the loop above left out of `named`. The
218 # `ROMAN` match is a test the predicate makes itself
219 # (`reads_as_generation`'s first), asked here at C level so a part
220 # ending in no numeral pays no frame for it, as group's count does
221 e = len(seg0) - 1
222 while e > 0 and seg0[e] not in named:
223 e -= 1
224 if (e > 0 and ROMAN.match(tokens[seg0[e]].text.rstrip(".")) is not None
225 and ends_in_a_generation(
226 [tokens[seg0[e - 1]].text, tokens[seg0[e]].text], one_case)):
227 named.discard(seg0[e])
228 if len(named) < 2:
229 return False
230 idx = list(seg0)
231 names = 0
232 start = 0
233 for end in unit_ends([surname_unit_facts(tokens[i].tags, k == 0)
234 for k, i in enumerate(idx)], chain=False):
235 if not named.isdisjoint(idx[start:end]):
236 names += 1
237 if names > 1:
238 return True
239 start = end
240 return False
241
242
243def _members(seg: Sequence[int], tokens: Sequence[WorkToken]) -> set[int]:
244 """The positions in `seg` of split credentials' members, for
245 `name_word_count`'s opening word (rules/S12), on the one-letter
246 numeral's rare path; `decide` scans for them inline."""
247 members: set[int] = set()
248 for k, i in enumerate(seg):
249 if SPLIT_MEMBER_TAG in tokens[i].tags:
250 members.add(k)
251 return members
252
253
254def _behind_a_generation(state: ParseState, seg0: Sequence[int],
255 p1: Sequence[list[int]], pt1: Sequence[set[str]],
256 tokens: Sequence[WorkToken], contrast: bool | None,
257 ) -> tuple[bool, ...] | None:
258 """rules/C12's reading of the part after the comma, or None where
259 the listing form stands: behind a whole name ending in a suffix
260 word, or in a numeral the trailing fork reads as the generation,
261 the part is the credential run, its titles (a word or a run a
262 connective joins) reading as titles, unless a word of it is
263 written as a name. The caller has tested the tag, or the numeral
264 shape with `ROMAN`, as a cheap filter. The part's veto is asked
265 first, being the cheap question and the one a listing with a given
266 name answers ('Garcia Lopez Jr., Juan'). `contrast` is C6's answer
267 where `decide` has already asked it, else None."""
268 # two name words and the generation at least: exact, since
269 # `_whole_name` needs two units in front ('Smith VI, AD')
270 if len(seg0) < 3:
271 return None
272 lex = state.lexicon
273 reading: list[bool] = []
274 for p, pt in zip(p1, pt1):
275 # a title reads as a title, a word or a run a connective joins
276 # ('Mr. and Mrs.', a dual inside it included); a lone dual of
277 # the title and the suffix vocabulary is the suffix here, the
278 # slot being postnominal ('John Smith Jr., AD RA'): C13's two
279 # predicates, asked in C13's order
280 title = (not is_suffix_piece(p, pt, tokens)
281 and is_title_piece(p, pt, tokens))
282 for i in p:
283 tok = tokens[i]
284 # rules/C12: "a word of it written as a name, or an
285 # initial, keeps the listing form" -- unlisted, and a word
286 # with a period is an abbreviation, as for the contrast;
287 # paired initials are initials, as C5 keeps them ('J.A.')
288 if "initial" in tok.tags or (
289 is_paired_initials(tok.text) if "." in tok.text
290 else _unclaimed_name_word(tok.text, lex)):
291 return None
292 reading.append(not title)
293 last = tokens[seg0[-1]].text
294 # a numeral by shape alone (VI to X) is the generation the trailing
295 # fork reads, as the listed ones are (#610's class: 'VI' beside 'V'
296 # and 'III'), and not one it reads as an initial's neighbour
297 # ('John J VI, AD'). C12's evidence is broader than a generation:
298 # any suffix word ('Jr.', 'Esq.') counts, by its tag
299 if ("vocab:suffix-word" not in tokens[seg0[-1]].tags
300 and not reads_as_generation(
301 last, tokens[seg0[-1]].tags, tokens[seg0[-2]].text,
302 state.one_case)):
303 return None
304 # the generation set aside, the name's own words in front of it are
305 # a whole name: 'Smith VI' is one word and a numeral, as 'Smith III'
306 # is, and a maiden clause is no word of the name ('Jane nee Smith
307 # Jr., AD' reads as 'Jane Jr., AD'; own_words, as for the contrast)
308 _, clause_at = own_words(state.tokens, state.comma_offsets,
309 lex._sets.maiden_markers)
310 front = [i for i in seg0[:-1]
311 if i < clause_at and tokens[i].role is None]
312 if not _whole_name(front, tokens, state.one_case):
313 return None
314 # a one-letter numeral is the generation only where the name has a
315 # word to spare for it, a title being none (rules/S10), and is no
316 # evidence where the name reads it as a name word ('Dr. Smith V,
317 # AD', whose title is a word to the count above but none here)
318 if (len(last.rstrip(".")) == 1 and is_single_letter_numeral(last)
319 and name_word_count([tokens[i].text for i in front], lex,
320 state.policy,
321 letters_one_case=state.one_case,
322 members=_members(front, tokens)) < 2):
323 return None
324 if contrast is None:
325 contrast = _name_contrast(state, seg0, clause_at)
326 if not contrast:
327 return None
328 return tuple(reading)
329
330
331def _given_part(token: WorkToken) -> PendingReading:
332 """The name reading of a word this decision bound as a credential:
333 the comma's other branch makes the part the given part, and with
334 every other word of it read as the decision read it, the word is
335 its given name (rules/C1's listing form)."""
336 return name_reading(token, Role.GIVEN)
337
338
339def _lone_word(state: ParseState, seg0: Sequence[int]) -> bool:
340 """Whether the head before the comma is one word as written, for
341 rules/C16's run of initials (#1238), where C4's count reads one name
342 word: a word the count leaves out as a title is a word here
343 ('Ashley Knight, M.S. BME'), and so is a particle unless written in
344 lower case in a head that holds a capital, in a name of mixed case
345 ('de Waart, J.P.', but 'Bin Zhang, M.D.', whose 'Bin' is as likely
346 the given name, and 'du juan, M.D.', written all in lower case). A
347 suffix word, or a numeral in capitals, of two or more letters ending
348 the head behind a word is none ('Jansen Jr., J.P.', 'Jansen VI,
349 J.P.', but 'Le Vi, M.D.' and 'Smith V, M.D.' are two words), and a
350 title left alone is no surname ('Dr. VI, M.D.')."""
351 tokens = state.tokens
352 # as C3 counts it: one in front of a word is a word ('Dr.B. Akila.,
353 # M.D'), a single letter ends a whole name (C1)
354 end = len(seg0)
355 while end > 1:
356 token = tokens[seg0[end - 1]]
357 stem = token.text.rstrip(".")
358 if len(stem) < 2 or not ("vocab:suffix" in token.tags
359 or stem.isupper()
360 and ROMAN.match(stem) is not None):
361 break
362 end -= 1
363 word: WorkToken | None = None
364 particle = False
365 for i in seg0[:end]:
366 token = tokens[i]
367 if "particle" in token.tags and token.text.islower():
368 particle = True
369 elif word is not None:
370 return False
371 else:
372 # a capitalized particle is a word ('Le, J.P.' one, 'Bin
373 # Zhang, M.D.' two), and so is a title word
374 word = token
375 if word is None:
376 # an empty head reads as C15 has it, as 'Smith, X' less the
377 # surname
378 return not particle
379 # a title left alone by the walk is no surname ('Dr. VI, M.D.'), a
380 # title word standing alone is one ('Knight, J.P.'); a lower-case
381 # word behind lower-case particles is a name all in lower case
382 return ((end == len(seg0) or "vocab:title" not in word.tags)
383 and not (particle and word.text.islower()))
384
385
386def _company(state: ParseState, seg0: Sequence[int], p: Sequence[int],
387 pt: Set[str], texts: dict[int, str],
388 contrast: list[bool]) -> bool:
389 """Whether one piece beside rules/C16's run is a credential that
390 makes the part the credential run (#1238): a suffix word that is
391 no generation and, if it is title vocabulary too, written in
392 capitals; a word of the ambiguous class; or an unlisted capitals
393 word C6 admits where the name carries its contrast (`contrast`
394 holds that answer once asked). A split credential, or a piece of
395 several words that is a suffix piece ('Ph. D.'), always is; a
396 numeral by shape, like a listed generation, never is."""
397 tokens = state.tokens
398 if "split" in pt:
399 return True
400 if len(p) != 1:
401 return is_suffix_piece(p, pt, tokens)
402 token = tokens[p[0]]
403 text = texts[p[0]]
404 if (GENERATION_WORD_TAG in token.tags
405 or ROMAN.match(text.rstrip(".")) is not None):
406 return False
407 if is_suffix_piece(p, pt, tokens):
408 return ("vocab:title" not in token.tags or text.isupper()
409 or bool(state.one_case))
410 if AMBIGUOUS_ACRONYM_TAG in token.tags:
411 return True
412 if (state.one_case
413 or not caps_shape_candidate(text, state.lexicon, state.policy,
414 one_case=False)):
415 return False
416 if not contrast:
417 contrast.append(_name_contrast(state, seg0))
418 return contrast[0]
419
420
421def _credentials_beside(state: ParseState, seg0: Sequence[int],
422 p1: Sequence[Sequence[int]],
423 pt1: Sequence[Set[str]], k: int,
424 texts: dict[int, str],
425 contrast: list[bool]) -> bool:
426 """Whether every piece of the part but the undotted pair at `k` is
427 a credential the pair's own writing would not doubt (rules/C5,
428 #1323), at least one of them behind it: a word of C16's company
429 that is no title in front of the pair, no word gated by the count
430 (S11: 'Junior', 'Filho', 'KC') and, where it is a member of the
431 ambiguous class, one its capitals settle ('MA', not 'Ed'); and the
432 name carries C6's contrast, asked here too, so a record in capitals
433 stays the listing form under every caps setting. A particle is no
434 credential ('Mc'), and a word S3 joins with a slash or a hyphen is
435 asked chunk by chunk, as its words written apart would be ('MD/Jr.',
436 'Ma/PhD'), a word the vocabulary lists whole being one word."""
437 tokens = state.tokens
438 lexicon = state.lexicon
439 for j, (q, qt) in enumerate(zip(p1, pt1)):
440 if j == k:
441 continue
442 if j < k and is_title_piece(q, qt, tokens):
443 return False
444 if len(q) == 1 and "split" not in qt:
445 token = tokens[q[0]]
446 if "particle" in token.tags:
447 return False
448 # a word the count gates (S11: 'Junior', 'Filho', 'KC') is
449 # refused here too: classify tags it a member of the class,
450 # and `settled_by_case` never settles a gated word
451 if (AMBIGUOUS_ACRONYM_TAG in token.tags
452 and not settled_by_case(token, state.one_case)):
453 return False
454 text = texts[q[0]]
455 if ("/" in text or "-" in text) and not _listed_whole(
456 text, lexicon):
457 for chunk in _joined_chunks(text, lexicon) or ():
458 folded = _normalize(chunk)
459 if (folded in lexicon._sets.generation_words
460 or folded in lexicon._sets.suffix_words_ambiguous
461 or ROMAN.match(chunk.rstrip(".")) is not None
462 or folded.replace(".", "")
463 in lexicon._sets.suffix_acronyms_ambiguous
464 and (state.one_case or not chunk.isupper())):
465 return False
466 if not _company(state, seg0, q, qt, texts, contrast):
467 return False
468 if not contrast:
469 contrast.append(_name_contrast(state, seg0))
470 return contrast[0]
471
472
473def _run_read_as_initials(state: ParseState, i: int) -> ParseState:
474 """The listing form, with the run of initials at `i` that made the
475 part the given part (rules/C16, #1238) tagged an initial, as a run
476 written apart is, so the given part's walk keeps it a name behind a
477 title ('Jansen, Ir. J.P.'), and the decision reported here where it
478 was taken: the other branch is the credential its letters spell."""
479 tokens = list(state.tokens)
480 token = tokens[i]
481 tokens[i] = copy_with(token, tags=token.tags | {"initial"})
482 return copy_with(state, tokens=tuple(tokens), ambiguities=(
483 *state.ambiguities,
484 PendingAmbiguity(
485 AmbiguityKind.SUFFIX_OR_NAME,
486 f"{token.text!r} after the comma also spells a credential; "
487 f"read as ", (i,), field_tail="",
488 alternative=suffix_reading(token))))
489
490
491def decide(state: ParseState, splits: bool | None = True) -> ParseState:
492 """The structure of a comma form, and the postnominal part bound.
493 Returns `state` unchanged where the listing form stands.
494
495 `splits` is for the retry below alone: None reads the part with each
496 split credential merged and asks nothing further, False with none
497 merged."""
498 if (state.structure is not Structure.FAMILY_COMMA
499 or len(state.segments) < 2):
500 return state
501 # rules/S12: "where the count declines it, the pair is the two words
502 # it is written as" -- the part read with each split credential
503 # merged, and read again as written where the reading did not take
504 # one as a credential, so a declined pair reads as it reads unmerged
505 # (an 'MB' it hid opens the part again, rules/C13). A loop, and the
506 # retry a call of this function only where a pair stands, so a part
507 # holding none pays no frame for it.
508 if splits:
509 heads: list[int] = []
510 for i in state.segments[1]:
511 if SPLIT_MEMBER_TAG in state.tokens[i].tags:
512 heads.append(i)
513 if heads:
514 out = decide(state, None)
515 if all(out.tokens[i].role is Role.SUFFIX for i in heads):
516 return out
517 return decide(state, False)
518 seg0, seg1 = state.segments[0], state.segments[1]
519 # Nothing after the comma: nothing to read. An EMPTY part BEFORE it
520 # is read on, being no whole name (`_whole_name` answers no):
521 # rules/C15: "the part after it reads as the given part after a
522 # family comma reads" -- so ', PhD' binds its credential as
523 # 'Smith, PhD' does; decisions/C15 (#892), decisions/C1 (#613)
524 if not seg1:
525 return state
526 tokens = list(state.tokens)
527 lex, pol = state.lexicon, state.policy
528 # rules/C10: "a suffix delimiter parts the suffix words on each side
529 # of it as a comma would" -- behind a whole name its cores are
530 # no word of the reading, and leave as group drops them in a tail;
531 # behind one name word the core is a word, v1 having applied the
532 # delimiter to the suffix-comma form alone (C10's core-is-a-word
533 # Accepted entry)
534 cores = delimiter_cores(pol.extra_suffix_delimiters)
535 inner = glued_cores(pol.extra_suffix_delimiters)
536 whole: bool | None = None
537 core_idx: list[int] = []
538 # a loop, and the head counted only where a core stands alone: the
539 # built-in ';', '-' and '/' leave `cores` non-empty for every
540 # policy (D15, #889)
541 if len(seg1) > 1:
542 for i in seg1:
543 if tokens[i].text in cores:
544 core_idx.append(i)
545 if core_idx:
546 whole = _whole_name(seg0, tokens, state.one_case)
547 if not whole:
548 core_idx = []
549 # a set for the membership test: a list scanned per token was
550 # quadratic in the part (#613's PR review, 'MD / '*k under '/')
551 core_set = frozenset(core_idx)
552 part = [i for i in seg1 if i not in core_set] if core_idx else list(seg1)
553 if not part:
554 return state
555 if pol.unlisted_dotted_suffixes:
556 # rules/S3: "Paired initials are the exception [...] where S4
557 # would count words to spare for them they are the name word
558 # they are written as" -- classify holds the class tag back from
559 # them, and at this comma C5 reads them instead (S3's Precedence
560 # bullet), so they rejoin the class for C5's count. Shape-tagged
561 # and untagged with the switch on is exactly that word
562 # (classify's dotted branch), asked of the tags in C
563 # (decisions/S3 (#864)).
564 for i in part:
565 tags = tokens[i].tags
566 if (SHAPE_ACRONYM_TAG in tags
567 and AMBIGUOUS_ACRONYM_TAG not in tags):
568 tokens[i] = copy_with(
569 tokens[i], tags=tags | {AMBIGUOUS_ACRONYM_TAG})
570 p1, pt1 = _reading_pieces(part, tokens, splits is not False)
571 anchored: list[int] = []
572 absorbed: list[int] = []
573 # A core in the part, standing alone or glued to a word or inside
574 # one: either keeps the part from the listing form's early return,
575 # and only a glued one reads behind any head, a core standing alone
576 # being a word behind one name word (C10's Accepted bullet). The
577 # shape tests below read a word with one glued at an end as the word
578 # without it, as classify tags it ('MBBS;' as 'MBBS'). A loop, so
579 # the listing form pays no frame for it.
580 cored = glued = False
581 texts: dict[int, str] = {}
582 for i in part:
583 text = texts[i] = tokens[i].text
584 here = False
585 if text in cores:
586 cored = True
587 else:
588 for core in inner:
589 if core in text:
590 cored = True
591 if glues(core):
592 here = glued = True
593 if here:
594 stem = glued_stem(text, inner)
595 if stem is not None:
596 texts[i] = stem
597 # The words of the class in the part, and the unlisted all-caps
598 # words the caps shape could admit: only a part holding one asks
599 # the count, so 'Smith, John' and 'John Smith, PhD' never build it.
600 # A split credential is a word of the class too (rules/S3), its
601 # piece tag the test.
602 maybe = [p[0] for p, pt in zip(p1, pt1) if "split" in pt
603 or len(p) == 1
604 and (AMBIGUOUS_ACRONYM_TAG in (tags := tokens[p[0]].tags)
605 or ("vocab:suffix" not in tags
606 and (texts[p[0]].isupper()
607 and (texts[p[0]].isalpha()
608 # the joined spelling ('PU-PH'), the
609 # predicate's other half (rules/S9)
610 or "-" in texts[p[0]]
611 or "/" in texts[p[0]])
612 or not unicodedata.is_normalized(
613 "NFC", texts[p[0]])
614 and is_caps_word(texts[p[0]]))))]
615 # rules/C16: "behind one word, initials run together are the given
616 # name whatever they spell" -- the runs that spell a listed
617 # credential ('J.P.', 'M.A.'), joined to the class so C4's count is
618 # taken (decisions/C16 (#1238)). Gathered only
619 # where the part holds a full stop, so 'Smith, John' pays no frame;
620 # a part whose other words are all credentials is C8's run
621 # ('Smith, M.D. Ph.D.').
622 runs: set[int] = set()
623 at = -1
624 for at, p in enumerate(p1):
625 if len(p) == 1 and "." in (text := texts[p[0]]):
626 tags = tokens[p[0]].tags
627 if ("vocab:suffix" in tags
628 and SHAPE_ACRONYM_TAG not in tags
629 and is_letter_run(text)
630 # every letter a capital ('J.씨' is a glued
631 # honorific; NFC first, a combining mark being no
632 # capital), or one case through the whole name; map
633 # over a builtin, so no frame per letter
634 and (all(map(str.isupper, letters := unicodedata.normalize(
635 "NFC", text.replace(".", ""))))
636 or state.one_case
637 and all(map(str.islower, letters)))):
638 # the first run is the one asked about; another run
639 # beside it is company like any credential ('Anderson,
640 # M.T. B.A.')
641 runs.add(p[0])
642 break
643 contrast_box: list[bool] = []
644 if runs:
645 first = p1[at][0]
646 # titles in front of the run are the given part's (C16);
647 # behind it a credential is company: a title/suffix dual only in
648 # capitals ('Smith, M.D. MS', but 'Smith, J.P. Ms.'), never a
649 # generation ('Rinehart, J.D. Jr.'), a spaced delimiter none
650 # ('Smith, M.D. / PhD'), and an unlisted capitals word where C6
651 # finds the name's contrast ('Smith, M.D. FRCS')
652 rest = [(p, pt) for k, (p, pt) in enumerate(zip(p1, pt1))
653 if (k > at or k < at and not is_title_piece(p, pt, tokens))
654 and not (len(p) == 1 and texts[p[0]] in cores)]
655 if rest and all(_company(state, seg0, p, pt, texts, contrast_box)
656 for p, pt in rest):
657 runs.clear()
658 elif first not in maybe:
659 maybe.append(first)
660 # rules/C8: "a part that is nothing but suffix words is the
661 # credential run and reads as suffixes, whole" -- the reading below.
662 # The part is read ONCE: here where it holds no word of the class,
663 # after the count below where it does, with the words the count
664 # licensed (a read here and a second after licensing threw the first
665 # away, #613's /simplify). A part holding a word no vocabulary
666 # claims as a suffix, and none of the class, is the given part: the
667 # listing form stands. (a declared delimiter's core may sit inside a
668 # word, 'RN/CRNA', which only the lenient fallback below reads)
669 reading: tuple[bool, ...] | None = None
670 # rules/C12's evidence, filtered by the tag and a C-level match so
671 # the listing form pays no frame for it ('Smith, John' never calls
672 # the helper)
673 generation = bool(seg0) and (
674 "vocab:suffix-word" in tokens[seg0[-1]].tags
675 or ROMAN.match(tokens[seg0[-1]].text.rstrip(".")) is not None)
676 behind_generation = listing = False
677 # C6's contrast, where the run's company has already asked it
678 contrast: bool | None = contrast_box[0] if contrast_box else None
679 if not maybe:
680 reading = segment_suffix_reading(
681 p1, pt1, tokens, pol.lenient_comma_suffixes, state.one_case,
682 anchored, absorbed)
683 # a word no vocabulary claims as a suffix: the listing form,
684 # unless C12 reads it below, which skips C1's fallback for it
685 listing = reading is None and not all(
686 "suffix" in t or "vocab:suffix" in tokens[p[0]].tags
687 for p, t in zip(p1, pt1))
688 # a part holding a delimiter core ('MD;', 'MD ; PhD') is left
689 # to C1's run test below, which reads a core alone as no word
690 # and a word with one glued as the word without it (rules/C10)
691 if cored:
692 listing = False
693 if listing and not generation:
694 return state
695 names = 0
696 caps: set[int] = set()
697 licensed: set[int] = set()
698 run = False
699 if maybe:
700 # `_members` inline: a frame here is paid by every name with the
701 # ambiguous class after its comma ('John Smith, MA')
702 split_at: set[int] = set()
703 for k, i in enumerate(seg0):
704 if SPLIT_MEMBER_TAG in tokens[i].tags:
705 split_at.add(k)
706 names = name_word_count([tokens[i].text for i in seg0], lex, pol,
707 letters_one_case=state.one_case,
708 members=split_at)
709 if names >= 2:
710 # rules/C6: "An unlisted all-caps word joins the ambiguous
711 # credential class [...] only where the name carries the contrast"
712 for i in maybe:
713 text = texts[i]
714 if SHAPE_ACRONYM_TAG in tokens[i].tags:
715 # tagged by classify (EVERYWHERE); the dotted shape has
716 # a period
717 if "." not in text:
718 caps.add(i)
719 continue
720 if (AMBIGUOUS_ACRONYM_TAG not in tokens[i].tags
721 and pol.unlisted_caps_suffixes is not CapsSuffixes.OFF
722 and caps_shape_candidate(text, lex, pol, one_case=False)):
723 if contrast is None:
724 contrast = _name_contrast(state, seg0)
725 # a two-capital word is how initials are written: the
726 # paired-initials rule below discards one nothing speaks
727 # for, a lone one included ('John Smith, XY')
728 if contrast:
729 caps.add(i)
730 # rules/C4: "The same count reads a part of two or more words
731 # as the credential run when every word of it is a suffix word
732 # or a word of this class", none of them a single-letter numeral.
733 # the words not of the class are asked as C1's suffix test asks
734 # them, the lenient word test by default ('Ma B.')
735 others: list[str] = []
736 for p, pt in zip(p1, pt1):
737 if ("split" in pt
738 or len(p) == 1
739 and (AMBIGUOUS_ACRONYM_TAG in tokens[p[0]].tags
740 or p[0] in caps)):
741 licensed.add(p[0])
742 else:
743 # a loop, not a comprehension: a listcomp is a frame
744 # per piece on 3.11
745 for i in p:
746 others.append(tokens[i].text)
747 run = (bool(licensed)
748 and (not others or is_wholly_suffix(
749 others, lex, pol, letters_one_case=state.one_case))
750 and not any(len(p) == 1
751 and is_single_letter_numeral(texts[p[0]])
752 for p in p1))
753 if not run:
754 licensed.clear()
755 else:
756 # rules/C5: "Paired initials are the exception to the [...]
757 # count" -- "Only an unambiguous suffix word in front of
758 # them that is not also title vocabulary, or another word
759 # the class admits by its dotted shape standing in the same
760 # part, makes them the credential run"
761 shape = {i for i in licensed
762 if i in caps or SHAPE_ACRONYM_TAG in tokens[i].tags}
763 # Where the first such suffix word stands, found once and
764 # only when a pair asks: a pair is spoken for exactly when
765 # it stands behind that word, so one scan answers every
766 # pair. Asked per pair, the scan re-walked every word in
767 # front of each one, quadratic in the part ('John Smith, ' +
768 # 'RA '*k + 'G.J. '*k, the duals never ending it; #563's
769 # shape, back in #613 and caught in review)
770 speaker = -1
771 for k, p in enumerate(p1):
772 i = p[0]
773 if len(p) != 1 or i not in licensed:
774 continue
775 text = texts[i]
776 if not (is_paired_initials(text)
777 or (i in caps and composed_len(text) == 2)):
778 continue
779 if speaker < 0:
780 speaker = next(
781 (j for j in range(len(p1))
782 if is_suffix_piece(p1[j], pt1[j], tokens)
783 and not is_title_piece(p1[j], pt1[j], tokens)),
784 len(p1))
785 # another shape word than this one: no set built per
786 # pair (a copy of `shape` each time was quadratic in C)
787 if not (speaker < k or len(shape) > 1
788 or (shape and i not in shape)
789 # rules/C5: "undotted, two capitals followed by
790 # credentials and standing beside nothing else
791 # are the credential run" ('Jane Doe, BM PhD');
792 # asked of the one shape word alone, so once
793 # per part (decisions/C5 (#1323))
794 or "." not in text and k + 1 < len(p1)
795 and _credentials_beside(state, seg0, p1, pt1, k,
796 texts, contrast_box)):
797 licensed.discard(i)
798 caps.discard(i)
799 if maybe:
800 lone = bool(runs) and names < 2 and _lone_word(state, seg0)
801 reading = segment_suffix_reading(
802 p1, pt1, tokens, pol.lenient_comma_suffixes, state.one_case,
803 anchored, absorbed, licensed, head_named=names >= 2,
804 runs=runs if lone else ())
805 if reading is None and lone:
806 # rules/C16: "behind one word, initials run together are the
807 # given name whatever they spell" -- the run the reading
808 # stopped at, the part's first piece that is no title (a
809 # name word in front of the run made the part the given part
810 # itself, and the run is that part's to read). Asked before
811 # C10's glued reading, so 'Smith, J.P.;' is the given name as
812 # its unlisted twin 'Smith, X.Q.;' is
813 k = next((j for j, p in enumerate(p1)
814 if not is_title_piece(p, pt1[j], tokens)), -1)
815 if k >= 0 and p1[k][0] in runs:
816 return _run_read_as_initials(state, p1[k][0])
817 if not listing:
818 if whole is None:
819 whole = _whole_name(seg0, tokens, state.one_case)
820 # rules/C1: "By default a recognized suffix word counts even
821 # written like an initial" -- behind a whole name ('John Smith, V.')
822 # rules/C10: "A delimiter glued to a suffix word parts it from
823 # the next as a comma glued there would" -- the part "reads as
824 # it does with a space in the delimiter's place", behind any
825 # head, so 'Smith, MD; PhD' reads as 'Smith, MD PhD' does; behind
826 # one that is not a whole name by the strict test, where "A
827 # glued word written like an initial is the given name"
828 # (decisions/C10, D15)
829 if reading is None and (whole or glued) and is_wholly_suffix(
830 [tokens[i].text for i in part], lex, pol, one_case=None,
831 letters_one_case=state.one_case, strict=not whole):
832 reading = (True,) * len(p1)
833 # the words of the part C12 reads as suffixes, which its report
834 # names: a title in the part, a word or a joined run, is no
835 # credential it took
836 taken: list[int] = []
837 if reading is None and generation:
838 reading = _behind_a_generation(state, seg0, p1, pt1, tokens,
839 contrast)
840 if reading is not None:
841 behind_generation = True
842 for p, suffix in zip(p1, reading):
843 if not suffix:
844 continue
845 for i in p:
846 taken.append(i)
847 text = texts[i]
848 # #564's marks on an unlisted all-caps word, as the
849 # caps shape writes them, so case repair keeps 'AD'
850 # (rules/R4); the C-level filter `maybe` uses first
851 if ("vocab:suffix" not in tokens[i].tags
852 and (text.isupper()
853 and (text.isalpha() or "-" in text
854 or "/" in text)
855 or not unicodedata.is_normalized(
856 "NFC", text) and is_caps_word(text))):
857 caps.add(i)
858 if reading is None:
859 return state
860 # rules/C7: "that reading is final" -- the part is the postnominal
861 # part, bound here whatever stands before the comma. The comma is a
862 # suffix comma behind a whole name; behind one name word it stays
863 # the family comma naming the family.
864 ambiguities = list(state.ambiguities)
865 for k, piece in enumerate(p1):
866 role = Role.SUFFIX if reading[k] else Role.TITLE
867 for i in piece:
868 tags = tokens[i].tags
869 if i in caps:
870 # #564: the caps shape's marks, as classify writes them
871 # EVERYWHERE, so case repair keeps 'XYZ' (rules/R4)
872 tags = tags | {SHAPE_ACRONYM_TAG, AMBIGUOUS_ACRONYM_TAG}
873 tokens[i] = copy_with(tokens[i], role=role, tags=tags)
874 # rules/C11: "A decision either way at the comma C1 reads is reported;
875 # for a run of words the decision is the flip to the credential
876 # run, reported once over the whole part" -- its Then, which this
877 # implements, with rules/C11's Then bullet "A run the count leaves
878 # in the listing form reports as S7 reads the words in it" and
879 # rules/C11's Except bullet "A flip in which no listed word of the
880 # ambiguous credential class (C4) takes part is the exception and
881 # is made in silence" -- "unless what said so is nothing but other
882 # paired initials", the clause that bullet ends on
883 # (decisions/C11 (#544)). A run whose every class word is listed and settled by
884 # its capitals is read on that evidence, not flipped by the count.
885 # A caps word bound above carries the class tags now, so membership
886 # is the tag alone; a part with no word of the class has none, and
887 # skips the bookkeeping (three list comprehensions, a frame each on
888 # 3.11, on every bound part)
889 members: list[int] = []
890 listed: list[int] = []
891 settled = pair_only = False
892 if maybe:
893 members = [p[0] for p in p1 if len(p) == 1
894 and AMBIGUOUS_ACRONYM_TAG in tokens[p[0]].tags]
895 listed = [i for i in members
896 if SHAPE_ACRONYM_TAG not in tokens[i].tags]
897 settled = (bool(members)
898 and len(listed) == len(members)
899 and all(listed_lean(tokens[i], state.one_case)
900 == "credential"
901 and (names >= 2
902 or AMBIGUOUS_WORD_TAG not in tokens[i].tags)
903 for i in listed)
904 # rules/S6: "a clash settled by the writing is not
905 # reported" -- a lone word only where it is an
906 # acronym its capitals settle
907 and (len(p1) > 1
908 or settled_by_case(tokens[listed[0]],
909 state.one_case)))
910 member_set = frozenset(members)
911 pairs = [k for k, p in enumerate(p1) if p[0] in member_set
912 and (is_paired_initials(texts[p[0]])
913 or (p[0] in caps
914 and composed_len(texts[p[0]]) == 2))]
915 # spoken for by each other only: every class word a pair, and
916 # only titles among the other words in front of the first
917 pair_only = (len(pairs) >= 2 and len(pairs) == len(members)
918 and all(p[0] in member_set
919 or is_title_piece(p, pt, tokens)
920 for p, pt in zip(p1[:pairs[0]], pt1)))
921 part_reported = behind_generation
922 if behind_generation:
923 # rules/C11: "A decision either way at the comma C1 reads is
924 # reported" -- here the suffix word before the comma decided it,
925 # not C4's count, which C12 does not consult
926 ambiguities.append(PendingAmbiguity(
927 AmbiguityKind.SUFFIX_OR_NAME,
928 f"{' '.join(tokens[i].text for i in taken)!r} after the comma "
929 f"follows {tokens[seg0[-1]].text!r}, so it is read as a "
930 f"credential run; it may be a name", tuple(taken),
931 alternative=_given_part(tokens[taken[0]])))
932 elif whole and run and ((listed and not settled) or caps or pair_only):
933 part_reported = True
934 if len(part) == 1:
935 what = (f"{tokens[part[0]].text!r} after the comma is also an "
936 f"ordinary name word")
937 else:
938 what = (f"{' '.join(tokens[i].text for i in part)!r} after the "
939 f"comma holds a word that is also an ordinary name word")
940 ambiguities.append(PendingAmbiguity(
941 AmbiguityKind.SUFFIX_OR_NAME,
942 f"{what}; the part before the comma holds {names} name words, "
943 f"so it is read as a credential run", tuple(part),
944 alternative=_given_part(tokens[part[0]])))
945 elif (len(p1[0]) == 1 and p1[0][0] in listed
946 and not (reading[0]
947 and settled_by_case(tokens[p1[0][0]], state.one_case))):
948 # the first word after the comma reports as it always does,
949 # naming the role bound above: the listing form binds a word
950 # the reading declines as a title ('John, Si.', #793), and no
951 # later rule moves a word of the emptied part; one its capitals
952 # settled as the credential does not
953 i = p1[0][0]
954 field = "a credential" if reading[0] else "a title"
955 ambiguities.append(PendingAmbiguity(
956 AmbiguityKind.SUFFIX_OR_NAME,
957 f"{tokens[i].text!r} after the comma is also an ordinary name "
958 f"word; read as {field}", (i,),
959 # the fork is credential or name: a word the reading
960 # declined is bound as a title, and its other branch is the
961 # credential
962 alternative=(_given_part(tokens[i]) if reading[0]
963 else suffix_reading(tokens[i]))))
964 # rules/S7: "A member the company decides reports the fork as a
965 # counted pick does" -- reported where it decided: a member the
966 # anchor read as a credential after its own writing declined (#544)
967 for k in anchored:
968 i = p1[k][0]
969 ambiguities.append(PendingAmbiguity(
970 AmbiguityKind.SUFFIX_OR_NAME,
971 f"{tokens[i].text!r} behind a credential after the comma is "
972 f"also an ordinary name word; read as a credential", (i,),
973 alternative=_given_part(tokens[i])))
974 # rules/C13: "A credential opening the part after a family comma
975 # makes it the postnominal part however it goes on" -- each word it takes that
976 # would have made it the given part, as #602's run reports them
977 for k in absorbed:
978 # a split credential the run takes reports itself, below
979 if has_name_content(p1[k], tokens) and "split" not in pt1[k]:
980 text = " ".join(tokens[i].text for i in p1[k])
981 ambiguities.append(PendingAmbiguity(
982 AmbiguityKind.SUFFIX_OR_NAME,
983 f"{text!r} follows a credential, so it reads as part of the "
984 f"suffix run; it may be a name word", tuple(p1[k]),
985 alternative=_given_part(tokens[p1[k][0]])))
986 # rules/S12: "where the count takes the pair, the reading is reported"
987 # -- C4's count took it, unless the flip it took part in reported
988 # the part
989 if not part_reported:
990 for k, piece in enumerate(p1):
991 if "split" in pt1[k] and reading[k]:
992 ambiguities.append(
993 split_credential_report(piece, tokens,
994 _given_part(tokens[piece[0]])))
995 # Every word of the part is bound above or dropped as a core, so the
996 # segment hands group nothing to join -- emptied here, where the
997 # binding is, as #601's take removes what it binds, rather than
998 # filtered out again in group (#613's /simplify)
999 return copy_with(state, tokens=tuple(tokens),
1000 segments=state.segments[:1] + ((),)
1001 + state.segments[2:],
1002 structure=(Structure.SUFFIX_COMMA
1003 if whole or behind_generation
1004 else Structure.FAMILY_COMMA),
1005 ambiguities=tuple(ambiguities),
1006 dropped=tuple(state.dropped) + tuple(core_idx))