1"""Shared vocabulary predicates for pipeline stages.
2
3Text-level tests used by more than one stage; piece-level ones live
4in _pieces, the sibling layer over tokens-plus-tags. All take
5normalized-or-raw text explicitly and no state, with the departures
6named below.
7
8is_wholly_suffix departs from that shape twice, deliberately. It is
9RUN-level rather than text-level, because the question it answers is
10genuinely about a run: the Ph./D. merge spans two tokens, so no
11per-token predicate composed with all() can express it. And it takes
12the Policy OBJECT, where delimiter_cores takes a pre-extracted
13frozenset so its caller hands in one field rather than the config --
14is_wholly_suffix needs TWO policy fields (lenient_comma_suffixes and
15extra_suffix_delimiters), and threading both past every caller costs
16more than the config parameter saves. Still no state: Policy is frozen
17config, not pipeline state.
18
19maiden_marker_run is run-level for the first of those reasons and not
20the second: a maiden marker may be a PHRASE ('z domu'), so how far one
21reaches is a question about a run of words that no per-word membership
22test can answer, and the vocabulary reaches it as a plain frozenset
23field like every other predicate here.
24
25tag_marker_runs is the third departure, and the reason this module also
26imports _state (WorkToken, comma_bucket): deciding which tokens open a
27maiden-marker RUN needs each token's role and span, not its text alone,
28because a run must not cross a role change or a comma. It moved here
29from classify (#289/#516) so _pieces.own_words -- which may import
30only _state and _vocab (tests/v2/test_layering.py) -- can call the
31SAME function classify does rather than approximating it with a
32text-only walk. Two callers building a map from the same tokens with
33the same function cannot disagree, which a from-scratch approximation
34could (measured: 'ANNA z Nowak, MD' flips the one-case verdict when
35the approximation and the real run-completion test disagree on
36whether 'z' opens a run that completes).
37
38suffix_reading, name_reading and the two reading constants are the
39fourth: they answer no question about a word but BUILD the reading a
40report did not take (#752 phase 2m), from a token's text and tags,
41which is why they import _state's PendingReading and the public enums
42it re-exports. They sit here because every stage that reports a fork
43builds one, and the tags they read are this module's vocabulary.
44
45Layering: imports _lexicon, _policy, and _pipeline._state (WorkToken,
46comma_bucket, PendingReading -- read-only, never a whole ParseState).
47"""
48from __future__ import annotations
49
50import functools
51import re
52import string
53import unicodedata
54from collections.abc import Callable, Iterable, Sequence, Set
55from typing import Literal
56
57from nameparser._lexicon import (
58 FULL_STOPS, Lexicon, _VOCAB_FIELDS, _normalize, _spells_an_initial,
59)
60from nameparser._policy import (CapsSuffixes, Policy, Script, _JA_SCRIPTS, _NO_INITIALS,
61 _SCRIPT_RANGES, _script_matcher)
62from nameparser._pipeline._state import (
63 AGNOMEN_TAG, AMBIGUOUS_ACRONYM_TAG, AMBIGUOUS_WORD_TAG, COMMA_CHARS, COMMA_STRIP,
64 CREDENTIAL_WORD_TAG, Deferred, GENERATION_WORD_TAG,
65 GIVEN_ABBREVIATION_TAG, HONORIFIC_WORD_TAG, PATRONYMIC_TAG, SHAPE_ACRONYM_TAG,
66 TITLE_AMBIGUOUS_TAG, PendingReading, ReadingKind, ReadingSource, Role, WorkToken,
67 comma_bucket,
68)
69
70# Ported verbatim from v1's "initial" pattern (the 1.x and 2.x
71# nameparser.config.regexes, removed in 3.0, #1008) minus its
72# empty-string alternative -- WorkToken text is never empty. _render
73# holds a hand copy, pinned to this one by tests/v2/test_regex_sync.py.
74# "Verbatim" is a promise about the PATTERN, not about the predicate:
75# since #320 is_initial is this SHAPE test ANDed with a repertoire test
76# (in_initialless_script, below), so _INITIAL.fullmatch(text) and
77# is_initial(text) are no longer the same question -- '씨.' answers yes
78# to the first and no to the second. Call is_initial; the bare pattern
79# is not the thing to ask. The narrowing lives in the predicate
80# precisely so this copy can stay exactly as verbatim as it ever was,
81# the pattern _render's copy must equal.
82_INITIAL = re.compile(r"^(\w\.|[A-Z])$")
83
84# Ported verbatim from v1's "period_not_at_end" pattern (the 1.x and
85# 2.x nameparser.config.regexes, removed in 3.0, #1008).
86_PERIOD_NOT_AT_END = re.compile(r".*\..+$", re.I)
87
88# The fix_phd credential pair ('Ph.' + 'D.' as adjacent tokens),
89# `split_pair`'s first answer, below.
90_PH = re.compile(r"^ph\.?$", re.IGNORECASE)
91_D = re.compile(r"^d\.?$", re.IGNORECASE)
92#: `split_pair`'s necessary half on its FIRST word for the Ph./D.
93#: answer alone, asked C-level by `is_wholly_suffix` ahead of the
94#: call, the one caller that asks only that answer and has no tags to
95#: read. The same compiled object the predicate asks, so the two
96#: cannot drift.
97SPLIT_PAIR_OPENS = _PH
98
99
100# The codepoint table lives in _policy beside Script -- one copy
101# importable from the pipeline and the locale packs alike; everything
102# here DERIVES from it. (single_script's sweep was first written
103# per-char on the _EMOJI_RANGES precedent in _tokenize.py, on the
104# theory that a range test needs no regex; measured at token scale the
105# compiled regex wins by 3-9x, and by roughly 70x on long tokens.)
106# Derived, never hand-written -- even the class construction goes
107# through the shared factory: one wholly-of predicate per script, in
108# the table's key order -- the FIRST-covering-entry rule _classify
109# documents.
110_SCRIPT_MATCHERS: dict[Script, Callable[[str], bool]] = {
111 script: _script_matcher(script, whole=True)
112 for script in _SCRIPT_RANGES
113}
114
115# The whole-token matcher over _policy's _JA_SCRIPTS union, backing
116# effective_script's kana license.
117_wholly_ja = _script_matcher(*_JA_SCRIPTS, whole=True)
118
119# The four kana voicing marks U+3099-U+309C (#596). None belongs to
120# either syllabary -- each voices the kana it follows -- but the script
121# table classifies by block and all four sit in the HIRAGANA one, so a
122# mark NFC cannot compose (ア + U+3099) made a katakana token read as
123# katakana plus hiragana and took the kana license.
124# _normalized_for_script drops every mark standing after a KANA, so the
125# mark takes its base's script. After anything else -- hangul, Han,
126# Latin, or nothing at all -- the mark is not voicing a kana and keeps
127# the table's answer: deleting it there would hand 김゙민준 a script the
128# surname site then divides on the RAW text, cutting the mark off its
129# base and leaving it to open the given name.
130_VOICING_MARKS = frozenset("\u3099\u309a\u309b\u309c")
131
132# Whole-text kana test for one character: the base a voicing mark may
133# be dropped after.
134_is_kana = _script_matcher(Script.HIRAGANA, Script.KATAKANA, whole=True)
135
136
137def _drop_kana_voicing_marks(text: str) -> str:
138 """`text` without the voicing marks that follow a kana (a run of
139 them included); every other mark kept. Called only on text holding
140 a mark, so names without one never pay for the walk."""
141 kept: list[str] = []
142 after_kana = False
143 for ch in text:
144 if ch in _VOICING_MARKS:
145 if after_kana:
146 continue
147 else:
148 after_kana = _is_kana(ch)
149 kept.append(ch)
150 return "".join(kept)
151
152# The repertoire half of is_initial (_policy._NO_INITIALS), kept apart
153# from _INITIAL's SHAPE half so the pattern itself stays v1-verbatim
154# and _render's copy stays pinned to it by tests/v2/test_regex_sync.py.
155# contains-any, not whole=True: the shape half has already admitted the
156# trailing period, so the text reaching here is '씨.' rather than '씨'
157# and a wholly-of match would be False for every case this exists for.
158# The second caller, is_title_shaped below, admits two or more
159# characters, so contains-any there means one CJK character anywhere
160# vetoes the whole word -- 'Kim김.' is refused as a title along with
161# '田中.' -- and that is deliberate: a word carrying a script with no
162# abbreviations is not wearing an abbreviation's period.
163_has_initialless_char = _script_matcher(*_NO_INITIALS, whole=False)
164
165
166def in_initialless_script(text: str) -> bool:
167 """Whether TEXT holds a character of a script that writes no
168 initials (#320), asked of the composed spelling, the one every
169 shape test beside it reads, so the veto and the shape read one
170 spelling (decisions/nfd-shapes, #665, #1087). ASCII answers without the
171 matcher's frame, as the callers' own `isascii()` gates do."""
172 return (not text.isascii()
173 and _has_initialless_char(unicodedata.normalize("NFC", text)))
174
175
176# H2's own shape, text-level: an unlisted period-marked abbreviation,
177# two or more letters, one trailing period. Moved here from _pieces
178# (#289/#516, quality-review finding) so _vocab.name_word_count can
179# ask the SAME question _pieces.is_leading_title asks -- measured
180# divergence before the move: 'Dr. Smith, Ed' (LISTED title 'Dr.') and
181# 'Xyz. Smith, Ed' (UNLISTED, H2-shaped) both read family 'Dr.
182# Smith'/'Xyz. Smith', given 'Ed' at the peel (since #701 the listed
183# 'Dr.' then peels into the title, rules/H6), because is_leading_title
184# reads H2's shape and a bare `folded in lexicon._sets.titles` lookup does
185# not -- but name_word_count's OWN count disagreed: 'Xyz.' counted as
186# a name word where 'Dr.' did not, so 'Xyz. Smith, Ed' alone flipped
187# the comma structure to a credential run
188# (mechanisms.md#ONE-PREDICATE-PER-QUESTION).
189#
190# v1's "period_abbreviation" shape, composed from `_LETTER` below, so
191# the text is v1's plus the marks. This is the only definition:
192# `_pieces._PERIOD_ABBREV` is an import of it, which
193# tests/v2/test_regex_sync.py declares as such.
194
195# `_LETTER` is the letter every shape regex in this module spells
196# (`_PERIOD_ABBREV`, `_DOTTED`, `_CHUNKED`, `_PAIRED_INITIALS`): one
197# letter (a word character that is not a digit or underscore) plus the
198# combining marks written after it (#652). `inž.` typed as i, n, z,
199# U+030C, '.' is the same word as the composed one, and `\w` alone ends
200# it at the z. `_MARKS` is the general combining blocks -- what NFD of
201# a Latin, Greek or Cyrillic letter produces -- and not the marks that
202# only some scripts write (Indic vowel signs, Arabic and Hebrew
203# points); rules/H2's Accepted bullet "The shape is only recognizable
204# as an unbroken run of letters" owns that limit (decisions/H2).
205_MARKS = '[\u0300-\u036f\u1ab0-\u1aff\u1dc0-\u1dff\u20d0-\u20ff\ufe20-\ufe2f]'
206_LETTER = r'[^\W\d_]' + _MARKS + '*'
207_PERIOD_ABBREV = re.compile(r'^(?:' + _LETTER + r'){2,}\.$')
208
209# rules/H2: "A word in one of the ten Brahmic scripts from Devanagari
210# to Sinhala" "never reads as a title by shape, even where it is a run
211# of letters" -- their Unicode blocks, U+0900 to U+0DFF. A vowel sign
212# already breaks `_LETTER`'s run, but a spelled initial written with
213# none (एम. for M., এম., ਐਮ.) is a run of two letters, and the shape
214# read it as a title; these scripts write initials, so the veto is the
215# title shape's alone and not `is_initial`'s (decisions/H2, #1191).
216# Searched on the raw text: a nukta letter decomposes inside the range.
217_INDIC_BRAHMIC = re.compile('[\u0900-\u0dff]')
218
219# rules/H2: "A transliteration digraph" "never reads as a title by
220# shape" -- the digraphs that Except names, lowercase and periodless,
221# so the test is a C-level membership of the word less its period;
222# the Except itself names the digraphs it leaves out (Ts, Ch, Sh) --
223# decisions/H2 (#1136).
224_DIGRAPH_INITIALS = frozenset({"yu", "ya", "ye", "yo", "zh", "kh",
225 "shch", "dzh"})
226
227#: The hyphens and the dashes that join two family names in one written
228#: word (rules/O6), and the parts of an abbreviation written hyphenated
229#: (rules/H5, 'Dipl.‐Ing.' as a word processor writes it): hyphen-minus,
230#: hyphen, non-breaking hyphen, figure dash, en dash, minus sign (a word
231#: processor's substitute), and the small and fullwidth hyphen-minus.
232#: The em dashes are punctuation between words, not joiners, and are
233#: left out. Hyphen-minus first, so it is literal inside a class.
234_HYPHEN_CHARS = "-\u2010\u2011\u2012\u2013\u2212\ufe63\uff0d"
235_HYPHENS = re.compile("[" + _HYPHEN_CHARS + "]")
236
237# rules/H5: "A word that ends in a period and is written with a
238# period or a hyphen inside it wears the abbreviation shape too" --
239# the trailing chain's shape, _PERIOD_ABBREV widened to 'Priv.-Doz.',
240# 'Dipl.Ing.' and 'Lt.Col.'. It opens and closes on a letter and ends
241# in one period, so every _PERIOD_ABBREV match is one of its matches;
242# the front slot's H2 shape stays _PERIOD_ABBREV (decisions/H5, #1116).
243_TRAILING_TITLE_SHAPE = re.compile(
244 r'^' + _LETTER + r'(?:' + _LETTER + r'|[' + _HYPHEN_CHARS + r'.])*'
245 + _LETTER + r'\.$')
246
247# rules/H5: "a title the vocabulary lists whole with a period inside
248# it chains without a final one" -- the letters of such a listing
249# joined by periods and hyphens ('dott.ssa', 'priv.-doz'), read by
250# classify on the normalized word, which has no edge periods, ANDed
251# there with a period test, since the pattern alone admits a word a
252# hyphen alone joins ('co-founder'); a digit keeps a rank written with
253# a period (a caller's 'cwo.3') out (decisions/H5, #1104).
254_JOINED_WORD = re.compile(
255 r'^(?:' + _LETTER + r')+(?:[' + _HYPHEN_CHARS + r'.]+(?:'
256 + _LETTER + r')+)+$')
257
258
259# rules/H2: "an abbreviation opening the part of the name that
260# carries the given name — the whole name, or the part after a
261# family comma — reads as a title even when unlisted"
262# (history: decisions/H2)
263def is_title_shaped(text: str, lexicon: Lexicon) -> bool:
264 """Whether TEXT reads as a title by H2's shape -- the LISTED half
265 being a separate lookup at each caller's own site
266 (is_title_piece/lexicon._sets.titles), and the one vocabulary read here
267 the given name written short, which outranks the shape (rules/H2,
268 #789: 'Chas.' is no title). `name_word_count`, `title_readings`
269 (classify's rules/H9 phrase test) and `_assemble`'s source of a
270 title no vocabulary tags are its callers; `_pieces.is_leading_title`
271 asks the same question but keeps this body inline, its path being
272 hot (every leading piece of every parse) where these run only on a
273 comma name, a name holding a contested title or modifier, or an
274 untagged title token
275 -- the measured figure lives at that inline copy and is not
276 restated here, two homes for one measurement being how they come
277 to disagree. Touch one, touch both:
278 `test_pieces.test_is_title_shaped_and_is_leading_title_agree`
279 runs both over the union of the two example tables, so the
280 agreement is checked rather than asserted in prose
281 (mechanisms.md#ONE-PREDICATE-PER-QUESTION).
282
283 The shape reads a Latin convention: a period marks an
284 abbreviation. Scripts with no initials have no period
285 abbreviations either (_policy._NO_INITIALS, the #320 veto
286 is_initial carries), so a CJK word wearing a period is a name
287 word, not a title -- a lone '田中.' is the family name (#323).
288 `_PERIOD_ABBREV` stays ASCII-period only: a word wearing '。' never
289 matches it, and the veto is what makes the ASCII spelling agree.
290 ASCII text can carry no _NO_INITIALS character (every range sits
291 above U+3000), so the C-level test declines before the regex
292 search runs -- four frames per unlisted-abbreviation opener per
293 parse, this running four times per piece.
294
295 A word holding a letter of the ten Brahmic scripts from Devanagari
296 to Sinhala is refused too (`_INDIC_BRAHMIC`, rules/H2's Except,
297 #1191): those scripts write initials, and spell a Latin one out by
298 its sound with no vowel sign (एम. for M.), a run of letters the
299 shape would misread. Its search is C-level, so it costs no frame.
300
301 A transliteration digraph (`Yu.`, `Zh.`, `Kh.`, `_DIGRAPH_INITIALS`)
302 declines the shape too, being the initial of one Cyrillic letter
303 (rules/H2's transliteration-digraph Except, #1136).
304 """
305 return (bool(_PERIOD_ABBREV.match(text))
306 and (text.isascii() or not (_INDIC_BRAHMIC.search(text)
307 or in_initialless_script(text)))
308 and text[:-1].lower() not in _DIGRAPH_INITIALS
309 and _normalize(text) not in lexicon._sets.given_name_abbreviations)
310
311
312def is_initial_shaped(text: str) -> bool:
313 """v1's is_an_initial verbatim: the SHAPE half alone -- one word
314 character plus a period, or a bare ASCII capital.
315
316 Callers asking whether a token is STRUCTURALLY part of an initial
317 run want this; callers asking whether it can really stand in for a
318 name want is_initial (#320). The two answers differ only inside
319 _NO_INITIALS scripts, where '씨.' is initial-SHAPED but is not an
320 initial -- see assign's roman-numeral fork, the shape caller, for
321 what picking the wrong one costs.
322
323 Asked of the composed spelling, as `_render` asks it (#542): `\\w`
324 does not match a combining mark, so a decomposed 'É.' is three
325 characters to the pattern and no initial (#665). Composing rather
326 than widening keeps the pattern v1-verbatim, and both calls are
327 C-level, so the predicate costs no frame it did not before."""
328 if not text.isascii():
329 text = unicodedata.normalize("NFC", text)
330 return bool(_INITIAL.fullmatch(text))
331
332
333# v1's "roman_numeral" pattern; _render's hand copy is pinned to this
334# one by tests/v2/test_regex_sync.py.
335_ROMAN = re.compile(r'^(X|IX|IV|V?I{0,3})$', re.I)
336#: The same pattern, shared rather than copied: `_comma` filters on
337#: it before asking `reads_as_generation` (rules/C12).
338ROMAN = _ROMAN
339
340
341# Ported verbatim from v1's patterns (the 1.x and 2.x
342# nameparser.config.regexes, removed in 3.0, #1008), the RU and TR_AZ
343# packs' copies pinned to these by tests/v2/test_locales.py: the East
344# Slavic patronymic endings and the standalone Turkic patronymic
345# markers, which rules/O1's and O2's rotations and rules/R9's reading
346# ask through the two predicates below.
347_EAST_SLAVIC = re.compile(
348 r"(ovich|ovna|evich|evna|ichna|ilyich|kuzmich|lukich|fomich|fokich|nikitich|savvich)$",
349 re.I)
350_EAST_SLAVIC_CYR = re.compile(
351 r"(ович|овна|евич|евна|ична|ильич|кузьмич|лукич|фомич|фокич|никитич|саввич)$",
352 re.I)
353_TURKIC = re.compile(
354 r"^(oglu|oğlu|ogly|ogli|o['’ʻ‘ʼ`]g['’ʻ‘ʼ`]li"
355 r"|qizi|qızı|kizi|kyzy|gyzy|uly|uulu)$", re.I)
356_TURKIC_CYR = re.compile(
357 r"^(оглу|оглы|оғлу|ўғли|угли|кызы|гызы|қызы|қизи|улы|ұлы|уулу)$", re.I)
358
359
360def has_patronymic_ending(text: str) -> bool:
361 """Whether `text` ends in an East Slavic patronymic ending, Latin or
362 Cyrillic ("Ivanovich", "Петрович"): rules/O1's test and, under
363 `PatronymicRule.EAST_SLAVIC`, rules/R9's."""
364 return bool(_EAST_SLAVIC.search(text) or _EAST_SLAVIC_CYR.search(text))
365
366
367def is_patronymic_marker(text: str) -> bool:
368 """Whether `text` is a standalone Turkic patronymic marker ("oglu",
369 "кызы"): rules/O2's test and, under `PatronymicRule.TURKIC`,
370 rules/R9's. Asked of the word NFC-composed (#1316): `oğlu` written
371 with a combining breve is the same marker, the vocabulary path's fold
372 applied to a shape test; the fold is inline behind `isascii()` so a
373 plain word costs no frame."""
374 if not text.isascii():
375 text = unicodedata.normalize("NFC", text)
376 return bool(_TURKIC.match(text) or _TURKIC_CYR.match(text))
377
378
379# rules/O1: "whose last name word carries a patronymic ending and whose
380# middle name word does not reads as family-first" -- the word test of
381# the rotation, shared with the group suggester (nameparser/_suggest.py),
382# which counts a patronymic vote where this rule can read one
383# (decisions/group-suggestion)
384def east_slavic_rotates(middle: str, last: str) -> bool:
385 """Whether the middle and last of three name words have rules/O1's
386 family-first shape: the ending on the last word and not on the
387 middle one. The word count and the initials beside it are the
388 caller's."""
389 return has_patronymic_ending(last) and not has_patronymic_ending(middle)
390
391
392# rules/S10: "the numeral reads as the generation: the numerals the
393# vocabulary lists, and those up to X it does not, by their shape
394# alone" -- its Then clause, the `_ROMAN` test; and rules/S10's Except
395# bullet "An initial in front makes it a name word too", the two
396# is_initial_shaped tests on `preceding`. The words-to-spare count,
397# rules/S10's Except bullet "A numeral of one letter needs words to
398# spare", is the callers'. decisions/S10 (#718, #721)
399# mechanisms.md#ONE-PREDICATE-PER-QUESTION ("exactly one predicate answers it and every other site calls that one")
400# -- the numeral's predicate, which every numeral rule asks
401# (decisions/vocabulary-model, #752; #784, #774, #819)
402def reads_as_generation(text: str, tags: Set[str], preceding: str | None,
403 one_case: bool | None) -> bool:
404 """Whether a word reads as the generation: S10's roman-numeral
405 fork, asked by every rule that weighs a numeral (#752). For a
406 one-letter numeral a necessary condition only: the words-to-spare
407 count is the caller's, which alone knows where the name's words
408 are (rules/S10, #718).
409
410 A numeral is I to X by shape, written with its periods or without
411 ('VI.', the German ordinal), save that a single letter written
412 with its period is an initial (rules/T5). The vocabulary's own
413 numerals of two or more letters ('ii' to 'iv', `tags` carrying
414 `vocab:suffix`) are the generation whatever stands around them,
415 the vocabulary settling them; the rest ('vi' to 'x', and the
416 one-letter 'i' and 'v') are weighed:
417
418 - an initial in front makes the numeral a name word: `preceding`
419 is the first token of the piece in front, read for layout, and
420 None where the slot never asks (the given part after a family
421 comma, where 'Chang, Andy C I' reads suffix 'I', v1's reading);
422 - a Title-case numeral in a name of mixed case leans to the name,
423 as a Title-case member of the ambiguous class does
424 (`numeral_leans_to_the_name`).
425
426 In the given part after a family comma (`preceding` None) a
427 one-letter numeral is the generation only where the vocabulary
428 lists it, rules/C9's "one-character suffix word": no count is
429 asked there (S5), and an unlisted letter is the middle initial
430 ('SMITH, JOHN X').
431
432 is_initial_shaped, not is_initial: this asks whether the preceding
433 piece looks like part of an initial run, which is a question
434 about layout, and #320 narrowed the tag to initials that can
435 really stand in for a name. Reading the tag here made '씨.' stop
436 suppressing the fork and cost 'John 씨. V' its family name.
437
438 `one_case` is ParseState.one_case: in a name written in one case
439 the word in front is read as its capital would be, so a bare 'j'
440 is initial-shaped as 'J' is (rules/S10, #721's review: 'john j v'
441 reads as 'JOHN J V'), while 'é', 'ж' or an uncased word, whose
442 capitals are not, stay as they were. None (the maiden take's
443 superset view passes it) asks neither case question, which admits
444 more and so stays a superset."""
445 stem = text.rstrip(".")
446 if not stem or _ROMAN.match(stem) is None:
447 return False
448 if len(stem) == 1:
449 # rules/T5: "A one-letter word written with its period is an
450 # initial"
451 if len(text) > 1:
452 return False
453 if preceding is None:
454 return "vocab:suffix" in tags
455 elif "vocab:suffix" in tags:
456 return True
457 elif one_case is False and stem.istitle():
458 return False
459 return (preceding is None
460 or not (is_initial_shaped(preceding)
461 or one_case and is_initial_shaped(preceding.upper())))
462
463
464# rules/C3: "a numeral ending the part, or standing before the listed
465# suffix words it ends with, that the trailing fork reads as the generation is
466# no name word" -- asked of the shape-only numerals,
467# the listed ones being suffix words to every count that asks it already;
468# decisions/C3 (#893)
469def ends_in_a_generation(texts: Sequence[str], one_case: bool | None,
470 ) -> bool:
471 """Whether the second of `texts`, two words of a name, is a
472 numeral of two or more letters that the trailing fork reads as the
473 generation behind the first. It reads no tags, so a listed numeral
474 ('III') answers True too: each caller passes only a word it has not
475 already read as a suffix, which leaves the unlisted VI to IX, and
476 finds the two words itself, the numeral ending the name words past
477 any suffix words behind it."""
478 last = texts[-1]
479 return (len(last.rstrip(".")) >= 2
480 and reads_as_generation(last, frozenset(), texts[-2], one_case))
481
482
483#: What a word of a clause S1 sets apart is: a title, a suffix, or a
484#: word that is both, its place in the name choosing (rules/S1).
485SetApartKind = Literal["title", "suffix", "dual"]
486
487
488# rules/S13: "Written with a period inside it, whatever case the name is
489# in" -- the writing that settles a member, asked of its text since a
490# clause word carries no shape tag (decisions/S13, #810, #1088)
491def dotted_member(word: str, lexicon: Lexicon) -> bool:
492 """Whether a word of a delimited clause is a member of the
493 ambiguous class (S4) written with a period inside it ('M.A',
494 'M.A.'), a comma the clause keeps on it left off (rules/N1). A
495 period ending the clause is extract's escape, not this."""
496 bare = word.strip(COMMA_STRIP)
497 return ("." in bare.strip(".")
498 and _normalize(bare).replace(".", "")
499 in lexicon._sets.suffix_acronyms_ambiguous)
500
501
502# rules/S1: "a clause wholly of words the vocabulary reads as no name
503# is set apart from the name" -- the word's half; `set_apart` is the
504# clause's. Its suffix half is `suffix_as_written`'s, so a credential
505# joined by a slash or a hyphen is one here as it is bare; a word its
506# periods join is asked first, then a dotted member, and a joined word
507# holding a member after the suffix test, each member left to S13's
508# position test (decisions/S1, #792, #1088; decisions/S3, #1131, #1138)
509def set_apart_kind(word: str, lexicon: Lexicon,
510 ) -> SetApartKind | Literal["member"] | None:
511 """What a word in a delimited clause is to S1's set-apart reading:
512 a title, a suffix (a word `suffix_as_written` reads, joined
513 credentials included, or one its periods join,
514 `period_joined_credential`), both, a member of the ambiguous class
515 written with a period inside it (`dotted_member`), a joined word
516 holding one (`joined_member`, 'MA/PhD') or a word its periods join
517 holding one ('Msc.Ed'), which S13 settles after the name only, or
518 None where the vocabulary reads it as a name or not at
519 all. A comma the clause keeps on its word is no part of it
520 (rules/N1)."""
521 bare = word.strip(COMMA_STRIP)
522 folded = _normalize(bare)
523 acronym = folded.replace(".", "")
524 sets = lexicon._sets
525 # rules/S1: "one joined by a slash, a hyphen or a period (S3) too,
526 # every chunk counting" -- a word with interior periods read here as
527 # at the end of a name ('(Msc.Ed)', '(Lt.Gov)'; not '(Bob.PhD)'),
528 # the period test asked at C level first (decisions/S3, #1131)
529 joined = ("." in folded and folded not in sets.titles
530 and acronym not in sets.suffix_acronyms)
531 title = folded in sets.titles or joined and period_joined_title(
532 bare, lexicon)
533 if joined and not title and period_joined_credential(bare, lexicon):
534 # rules/S3: "a joined word holding a member is a member itself"
535 # -- 'Msc.Ed' as 'MA/PhD', settled by S13's position (#1138)
536 return "member" if _holds_a_member(bare, lexicon) else "suffix"
537 # a member of the ambiguous class, the set asked inline as the
538 # necessary half of `dotted_member`, so no other word pays its
539 # frame: `suffix_as_written` would read a dotted member as a suffix
540 # wherever it stands, and reads no other member as one
541 if acronym in sets.suffix_acronyms_ambiguous:
542 if title:
543 return "title"
544 return "member" if "." in bare and dotted_member(
545 bare, lexicon) else None
546 # the sets and the joiners asked first, at C level, the necessary
547 # half of `suffix_as_written`, so a word it could never read (a
548 # nickname) pays no frame
549 suffix = ((acronym in sets.suffix_acronyms or folded in sets.suffix_words
550 or "-" in folded or "/" in folded)
551 and suffix_as_written(folded, bare, lexicon))
552 # rules/S3: "a joined word holding a member is a member itself" --
553 # settled after the name only, as S13 settles one written alone
554 # (decisions/S3, #1138)
555 if suffix and not title and joined_member(bare, lexicon):
556 return "member"
557 if title:
558 return "dual" if suffix else "title"
559 return "suffix" if suffix else None
560
561
562#: Single letters each followed by a period ('R.A.'), the writing that
563#: makes a word that is a title too a set of initials (rules/S1).
564_DOTTED_INITIALS = re.compile(r"(?:[^\W\d_]\.)+")
565
566#: A word written as initials: one or two letters, with or without a
567#: period, or single letters each followed by a period ('JP', 'Md',
568#: 'MD.', 'A.M.', 'R.E.S.').
569_INITIALS_SHAPE = re.compile(r"[^\W\d_]{1,2}\.?|" + _DOTTED_INITIALS.pattern)
570
571
572# rules/S1: "A credential written as initials before a name word stays
573# the nickname, and so does any word of its clause" -- asked of each
574# chunk of a credential joined by a slash or a hyphen, so it reads
575# before a name word as its split spelling does (decisions/S1, #792,
576# #1088)
577def reads_as_initials(word: str, kind: SetApartKind,
578 one_case: bool | None, lexicon: Lexicon) -> bool:
579 """Whether a word of a clause S1 would set apart counts, before a
580 name word of its comma part, as the initials the person goes by: a
581 credential written in their shape that is no generation word, and a
582 word that is a title too only written with a period after each
583 letter ('R.A.') or in capitals in a name of mixed case ('RA'),
584 Title case reading as the title it also is and capitals in a name
585 written in one case saying nothing (S6); a title alone never. A
586 member of the ambiguous class counts as the credential it is
587 settled as. `one_case` is ParseState.one_case. A joined credential
588 counts where any of its chunks would ('JP/MD', 'MD/PhD'), as its
589 words written apart do, and one the vocabulary lists whole never
590 ('NP-C'), a run of its chunks listed whole being one chunk
591 ('PhD/NP-C' is 'PhD', 'NP-C', as `_joined_chunks` cuts it). A
592 chunk is asked as the word it is, with no further cut by this
593 function, so its loop is one level deep."""
594 bare = word.strip(COMMA_STRIP)
595 # a credential the vocabulary lists whole ('NP-C', 'SHRM-CP') is one
596 # word, not two S3 joins, and has no other spelling written apart
597 if kind == "suffix" and ("/" in bare or "-" in bare) and not (
598 _listed_whole(bare, lexicon)):
599 # a word with no cut S3 admits has no chunk to ask; unreached by
600 # a parse, where a joined word not listed whole is a suffix only
601 # through a cut
602 for chunk in _joined_chunks(bare, lexicon) or ():
603 if _shaped_as_initials(chunk, set_apart_kind(chunk, lexicon),
604 one_case, lexicon):
605 return True
606 return False
607 # rules/S1: "a credential S3 reads through chunks joined by a slash,
608 # a hyphen or a period counts where any of its chunks would" -- a
609 # period-joined word's chunks asked as a slash's are ('JP.MD' as
610 # 'JP/MD'), one listed whole ('M.D.', 'MD.PhD') never (#1131)
611 if (kind == "suffix" and "." in bare.strip(".")
612 and _normalize(bare).replace(".", "")
613 not in lexicon._sets.suffix_acronyms
614 and period_joined_credential(bare, lexicon)):
615 # a single letter before a glued honorific is the initial it is
616 # written as ('J.씨', the one chunk the every-chunk test lets
617 # count as nothing)
618 return any(len(chunk) == 1 and chunk.isalpha()
619 or _shaped_as_initials(chunk, set_apart_kind(chunk, lexicon),
620 one_case, lexicon)
621 for chunk in _period_chunks(bare) or ())
622 return _shaped_as_initials(bare, kind, one_case, lexicon)
623
624
625def _shaped_as_initials(bare: str,
626 kind: SetApartKind | Literal["member"] | None,
627 one_case: bool | None, lexicon: Lexicon) -> bool:
628 """`reads_as_initials` for one word, or one listed run of a joined
629 word, its kind as `set_apart_kind` gives it; a run holding a joiner
630 never matches the initials shape."""
631 return ((kind == "suffix" or kind == "member" or kind == "dual" and (
632 _DOTTED_INITIALS.fullmatch(bare) is not None
633 or one_case is False and bare.isupper()))
634 and _INITIALS_SHAPE.fullmatch(bare) is not None
635 and _normalize(bare) not in lexicon._sets.suffix_words)
636
637
638def set_apart(words: Sequence[str],
639 kinds: Sequence[SetApartKind],
640 behind_a_credential: bool, lexicon: Lexicon) -> bool:
641 """rules/S1's clause tests over the words of a delimited clause,
642 each already found to be no name by `set_apart_kind` (or, after
643 the name, a member its writing settles, rules/S13): a clause of
644 nothing but title modifiers and styles is no title ('(UK)',
645 '(Hon)'), a lone title of address may be the name the person goes
646 by ('(Sir)', '(Mr)': a title addressing the given name, or a
647 salutation that may end the given part, rules/H8), and a clause
648 of titles alone straight behind a credential qualifies that
649 credential ('FRCS (SN)'). `behind_a_credential` is whether the
650 word in front of the clause was read as a suffix, which extract,
651 running before any word is read, passes as False."""
652 sets = lexicon._sets
653 folded = [_normalize(w.strip(COMMA_STRIP)) for w in words]
654 if all(f in sets.title_modifiers or f in sets.title_styles
655 for f in folded):
656 return False
657 if len(folded) == 1 and (folded[0] in sets.given_name_titles
658 or folded[0] in sets.after_given_titles):
659 return False
660 return not (behind_a_credential and "suffix" not in kinds
661 and "dual" not in kinds)
662
663
664# rules/S10: "a Title-case numeral in a name of mixed case leans to the
665# name" -- the fork weighed it and the writing declined it, so it is
666# reported as a declined member of the ambiguous class is (#752)
667def numeral_leans_to_the_name(text: str, tags: Set[str],
668 preceding: str | None,
669 one_case: bool | None) -> bool:
670 """Whether `reads_as_generation` declined a numeral for its Title
671 case alone: two or more letters, unlisted, in a name of mixed case,
672 and no initial in front (which makes it a name word unweighed)."""
673 return (one_case is False and text.rstrip(".").istitle()
674 and len(text.rstrip(".")) >= 2 and "vocab:suffix" not in tags
675 and reads_as_generation(text, tags, preceding, None))
676
677
678def is_listed_numeral(text: str, tags: Set[str]) -> bool:
679 """A numeral the vocabulary lists in two or more letters ('III'):
680 not in question, so its reading is never reported (rules/S10)."""
681 stem = text.rstrip(".")
682 return (len(stem) >= 2 and "vocab:suffix" in tags
683 and _ROMAN.match(stem) is not None)
684
685
686# rules/S10: "a numeral written in lower case in a name of mixed case is
687# the generation and reports nothing" -- the one writing that settles
688# the fork, since nobody writes an initial in lower case beside
689# capitals. Asked by the walk, which takes such a numeral as any suffix
690# word and records no call, and by the report on a numeral before a
691# credential (`reads_as_a_reported_numeral`).
692def numeral_settled_by_case(text: str, one_case: bool | None) -> bool:
693 """Whether a numeral the trailing fork reads is lower case in a
694 name written in more than one case. `one_case` is
695 ParseState.one_case; None (nobody asked) settles nothing."""
696 return one_case is False and text.islower()
697
698
699# rules/S10: "either reading is reported" -- the one question the two
700# reports on a numeral a post-nominal follows ask (assign's, and the
701# maiden take's for a numeral its clause keeps): the fork would read
702# this numeral, and its writing does not settle it (#721)
703def reads_as_a_reported_numeral(text: str, tags: Set[str],
704 preceding: str,
705 one_case: bool | None) -> bool:
706 """Whether a numeral the walk left a name word, with `preceding`
707 in front of it, is one the fork would weigh and so reports: one it
708 reads as the generation, or declines for its Title case."""
709 return ((reads_as_generation(text, tags, preceding, one_case)
710 or numeral_leans_to_the_name(text, tags, preceding, one_case))
711 and not numeral_settled_by_case(text, one_case))
712
713
714# #544: a single-letter roman numeral ('V', 'v', 'I.'), in any case,
715# is excluded for its SHAPE -- one letter is how a middle initial is
716# written, and rules/S3 retires single-character matches for the
717# same reason -- not for being a generation: a multi-letter suffix
718# word ('III', 'Jr') anchors and runs like any other. So it neither
719# anchors an ambiguous member behind it nor counts toward C4's
720# multi-word credential run. It still peels exactly as before; this
721# answers only those two questions.
722def is_single_letter_numeral(text: str) -> bool:
723 """One letter, optionally followed by periods, that is a roman
724 numeral ('V', 'v', 'I.', 'X')."""
725 letters = text.rstrip(".")
726 return len(letters) == 1 and _ROMAN.match(letters) is not None
727
728
729# rules/T5: "A one-letter word written with its period is an initial,
730# in a script that writes initials, and so is a bare unaccented Latin
731# capital, A to Z"
732def is_initial(text: str) -> bool:
733 """'A.' / 'j.' / bare capital -- v1's is_an_initial, narrowed to
734 scripts that HAVE initials (#320). v1's \\w is Unicode-aware and
735 matched CJK too, which made period-written CJK honorifics ('씨.')
736 fail is_suffix_strict -- the veto in _is_suffix_strict_n, NOT the
737 vocabulary: suffix_as_written has no veto, so classify tagged '씨.'
738 'vocab:suffix' either way, and is_suffix_lenient took it either way
739 too. Downstream of that one strict-test No, the glued honorific in
740 a name carrying such a token went unpeeled ('田中さん 様.')."""
741 return is_initial_shaped(text) and not in_initialless_script(text)
742
743
744def is_one_case(texts: Sequence[str]) -> bool:
745 """Whether a name is written wholly in ONE case -- all upper or all
746 lower alike -- and so carries no case EVIDENCE about any letter in
747 it (rules/P8, #383/#479). The caller passes the name's OWN
748 words: a maiden marker's clause and any delimited (nickname)
749 content are not among them, and appending one must not flip the
750 reading of words that did not change.
751
752 Mirrors the SHAPE of the comparison the R5 gate in
753 `_render.capitalized` makes, not its SPAN: R5 joins every token but
754 the suffixes, nickname and maiden content included, while
755 classify's caller hands in only the name's own words (rules/P3's
756 own-words doctrine, see above) -- so a clause-bearing name can be
757 one-case to this function and mixed to R5 (measured:
758 `'JUAN GARCIA Y LOPEZ née Jones'` is one case here, mixed there),
759 and a name with a cased suffix mixed here and one-case there
760 (`'john e jones III'`). Not shared by import today -- render is a
761 layer this module does not reach into -- and #492 left the two
762 spans apart by decision (decisions/R5, "THE R5 / PARSER SPLIT").
763
764 `Sequence`, not `Iterable`: the caller passes a list it already
765 built rather than a fresh generator, so `is_one_case` costs one
766 profiler frame per parse rather than one per token (#475).
767
768 A CASELESS script answers True, harmlessly: `'محمد و علي'.upper()`
769 is the string itself, so the comparison holds. Its callers are
770 classify, segment (only where C2's flag on a tail part asks) and
771 script_segment's `_one_case`; the first two record the answer as
772 `ParseState.one_case`, and script_segment does not; the decisions
773 that read it each also require the token they judge to be CASED:
774 classify's connective-or-initial fork asks for a token whose own
775 `upper()` and `lower()` differ, `ambiguous_lean` -- which the
776 suffix readers in `_pieces`, `_group` and `_assign` reach through
777 `listed_lean` -- returns no lean for a caseless token, and
778 `caps_shape_candidate` requires
779 `isupper()`. A caseless letter is none of those, so a caseless
780 name never reaches a decision this gates, and "one case" is the
781 honest verdict for text that has only one.
782 """
783 joined = " ".join(texts)
784 return joined in (joined.upper(), joined.lower())
785
786
787#: Which way the WRITING leans for a member of the ambiguous
788#: credential class; None is no lean at all. Named so the two
789#: predicates that answer it -- `ambiguous_lean` here and
790#: `_pieces.listed_lean`, which wraps it -- carry the same three-value
791#: type rather than a bare `str`. Under mypy's `strict_equality` that
792#: makes a misspelled comparison (`== "credentail"`) an error at the
793#: two call sites that branch on the answer, where a `str` return
794#: leaves it silently False forever -- which is what the value is FOR:
795#: `_pieces.peel_trailing` peels or declines on it.
796#: `period_joined_vocab`'s own verdict is spelled inline for the same
797#: reason; it has one caller shape and no wrapper to keep in step.
798Lean = Literal["credential", "name"]
799
800#: How a member's writing leans, by what the member is (rules/S11):
801#: an acronym's capitals are the credential, the lower case of a suffix
802#: word written `title_case_is_name` is the suffix, and any other suffix
803#: word's writing (an agnomen's, `Junior`'s) says nothing (#1039).
804Cue = Literal["acronym", "word", "none"]
805
806
807# #289's credential lean, rules/S6: "Written case is evidence beside
808# the count (S4), and it speaks only in a name written in more than
809# one case" -- the WRITING evidence that decides ahead of the
810# words-to-spare count (history: decisions/S6).
811def ambiguous_lean(text: str, one_case: bool,
812 cue: Cue = "acronym") -> Lean | None:
813 """Which way the WRITING leans for a member of the ambiguous
814 credential set: "credential", "name", or None for no lean at all.
815
816 None is the fall-through to rules/S4's count, and it is the
817 answer for three inputs. A name written wholly in one case says
818 nothing about any word in it. A member written wholly in lower
819 case says nothing either -- lower is how most of a mixed-case
820 name is written, so it is not a contrast. And a CASELESS token
821 ('씨', '毛') can be written against nothing, so neither test
822 fires and the count decides, which is what keeps this rule out of
823 a caseless script by construction.
824
825 `one_case` is the recorded fact (ParseState.one_case), taken over
826 the name's OWN words -- handed in rather than recomputed, because
827 three sites read this and a fact two of them derived apart is the
828 bug the field exists to prevent
829 (mechanisms.md#ONE-PREDICATE-PER-QUESTION).
830
831 The caller decides MEMBERSHIP: this answers only about the
832 writing. Periods do not disturb either test, '.' having no case,
833 so 'MA.' leans as 'MA' does -- the lean reads CASE, not periods,
834 and the dotted spelling is the vocabulary's own question
835 (suffix_as_written).
836
837 `cue` is what the member is (rules/S11). A suffix WORD that is
838 also a name and whose Title case is its writing as a name ("word",
839 'fils', 'kc', written `title_case_is_name`) leans the other way at
840 one end: its lower case in a mixed-case name is the suffix
841 ("credential", the suffix reading), its capitals say nothing (a
842 surname is often written so), and Title case leans to the name as
843 an acronym's does. Any other ("none": an agnomen, 'Neto', written
844 in the case of the name around it; 'Junior', whose Title case is
845 the generation's writing too) never leans (#1039).
846 """
847 if one_case or cue == "none":
848 return None
849 if text.upper() == text.lower(): # caseless: no contrast to read
850 return None
851 if text.isupper():
852 return "credential" if cue == "acronym" else None
853 if text.islower():
854 return "credential" if cue == "word" else None
855 return "name"
856
857
858def lexicon_cue(n: str, lexicon: Lexicon) -> Cue:
859 """The `ambiguous_lean` cue of the folded member `n`, read off the
860 lexicon by a caller that has no tags to read (`is_wholly_suffix`);
861 `_pieces.listed_lean` reads the same answer off classify's tags."""
862 if n in lexicon._sets.suffix_words_ambiguous:
863 return ("word" if n in lexicon._sets.title_case_name_words
864 else "none")
865 return "acronym"
866
867
868_DOTTED = re.compile(r"(?:" + _LETTER + r"\.)+")
869# rules/T7: "a run of single letters joined by full stops" -- two or
870# more letters, each closed by a stop but the last, whose stop is
871# optional ('E.T.', 'J.R.R', #1296), and the same run rules/H10 reads as
872# a title ('U.S.', 'U.S', the closing stop optional as
873# `period_joined_vocab`'s shape verdict lets it be); _RUN_LETTER is one letter of it
874# with its stop. _render holds a copy of each (tests/v2/test_regex_sync.py)
875_LETTER_RUN = re.compile(r"(?:" + _LETTER + r"\.)+" + _LETTER + r"\.?")
876
877
878_RUN_LETTER = re.compile(_LETTER + r"\.?")
879
880
881def is_letter_run(text: str) -> bool:
882 """Two or more single letters joined by full stops ('J.P.', 'J.R.R'),
883 the way a person's initials are written run together: rules/T7's
884 run, and the shape rules/C16 reads as a name after the comma behind
885 one name word whatever it spells (#1238)."""
886 return _LETTER_RUN.fullmatch(text) is not None
887
888
889# #544: the CHUNKED spelling -- two or more runs of letters, each
890# closed by a period ('M.Eng.', 'L.Ac.'). Both callers compare the
891# period-free letters against the listed member, so the chunks must
892# spell it; one chunk ('Ma.', 'Ed.') is the single trailing period and
893# stays outside the gate.
894_CHUNKED = re.compile(r"(?:(?:" + _LETTER + r")+\.){2,}")
895
896
897def _dotted(text: str) -> bool:
898 """Written with its periods: one after each letter ('M.A.',
899 'J.D.'), the acronym's own spelling, or -- since #544 -- two or
900 more letter chunks each closed by a period ('M.Eng.', 'L.Ac.'), the
901 spelling a member with a lower-case tail is written in. A single
902 trailing period ('Ma.', 'Ed.', 'Ms.') is the abbreviation shape any
903 word can wear -- the honorific's, a name's -- and is not the gate's
904 "written with periods" (rules/S3). Until #296's review the gate
905 was "any period", and 'Smith, Ms.' passed it as the degree."""
906 return (_DOTTED.fullmatch(text) is not None
907 or _CHUNKED.fullmatch(text) is not None)
908
909
910# rules/T8: "glued to the next at its full stop" -- the shapes
911# `glued_cuts` reads (decisions/T8 (#1230, #1235)): one chunk of letters
912# closed by a stop with something after it
913_GLUED_CHUNK = re.compile(r"((?:" + _LETTER + r")+)\.(?=\S)")
914# a run of two or more single letters closed by stops, a letter after
915# it (the name is `glued_cuts`' to find)
916_GLUED_RUN = re.compile(r"(?:" + _LETTER + r"\.){2,}(?=[^\W\d_])")
917#: a letter (its marks with it, so NFD text passes), a full stop and a
918#: letter: the C-level test `_script_segment` asks before `glued_cuts`
919GLUED_SHAPE = re.compile(_LETTER + r"\.[^\W\d_]")
920# the Thai vowel and tone marks (Mn), which `_LETTER` does not count:
921# nearly every Thai name writes one ('สุดา', 'ใจดี'), so without them the
922# name after a Thai title's stop is never found (#1166)
923_THAI_MARKS = "[\u0e31\u0e34-\u0e3a\u0e47-\u0e4e]"
924# one word of letters, apostrophes and hyphens: what can follow a chunk
925# and be a name
926_GLUED_WORD = re.compile(
927 r"(?:" + _LETTER + r")(?:" + _LETTER + "|" + _THAI_MARKS + r"|['’\-])*")
928
929
930def _is_cased_letter(chunk: str) -> bool:
931 """One letter in a script with case: the initial T8 reads alone
932 (rules/T8); a single letter in a script without case is read alone
933 only where it is listed, as a title or a given name written short."""
934 return len(chunk) == 1 and chunk.upper() != chunk.lower()
935
936
937def _reads_alone(chunk: str, lexicon: Lexicon) -> bool:
938 """A chunk the parser reads on its own before a full stop: one
939 letter in a script with case, a transliteration digraph rules/H2
940 names, a listed title that is no saint particle, or a listed given
941 name written short (decisions/period-joined-words, #1255). A single
942 letter in a script without case is read alone only where it is
943 listed (Thai 'ศ.', #1166); a run of them is T7's run in any script."""
944 chunk = unicodedata.normalize("NFC", chunk)
945 if _is_cased_letter(chunk):
946 return True
947 n = _normalize(chunk)
948 sets = lexicon._sets
949 if n in _DIGRAPH_INITIALS or n in sets.given_name_abbreviations:
950 return True
951 return is_title_chunk(chunk, lexicon)
952
953
954def glued_cuts(text: str, lexicon: Lexicon) -> tuple[int, ...]:
955 """Where a word glued to the next at a full stop divides (T8,
956 #1230, #1235): the offset after each stop at which a chunk the
957 parser reads alone (`_reads_alone`) meets a name, so the word reads
958 as it does with a space there ('Dr.John' as 'Dr. John',
959 'Ц.Сандаг-Очир' as 'Ц. Сандаг-Очир', 'Yu.A.Chibrikova' as 'Yu. A.
960 Chibrikova'). A run of single letters stays one chunk before the name
961 ('J.R.Smith' as 'J.R. Smith', rules/T7), while titles glued to each
962 other divide at each stop, as they read written apart ('Lt.Gov.John'
963 as 'Lt. Gov. John'). Empty where the vocabulary reads the word whole
964 (a suffix spelled without its stops, 'M.Sc', 'M.Sc.'; a title listed
965 whole), and wherever the
966 word does not divide through to its end: at a word in lower case
967 ('Dr.med', 'G.ppe'), at one closed by its own stop or followed by
968 another chunk ('Dr.Med.', 'J.Smith.', 'Dr.John.Smith'), and at an
969 unambiguous title or credential after the stop ('Dr.MD')."""
970 sets = lexicon._sets
971 n = _normalize(text)
972 bare = n.replace(".", "")
973 # a title listed whole is read whole, as a suffix spelled without its
974 # stops is: with its stops, or without them where no initial ends it
975 # ('Dr.Ma' where a caller lists 'drma'; 'Dr.A.' divides, 'dra' being
976 # the title and the initial T8 reads behind one)
977 if (bare in sets.suffix_acronyms or n in sets.titles
978 or bare in sets.titles and len(unicodedata.normalize(
979 "NFC", n.rsplit(".", 1)[-1])) > 1):
980 return ()
981 low = text.lower()
982 dotted = _dotted_titles(sets.titles)
983 if len(low) != len(text):
984 # offsets in `low` would not be the text's
985 dotted = ()
986 cuts: list[int] = []
987 at = 0
988 titled = False
989 # the cuts stand only where the word divides through to its end: a
990 # name closing it ('Yu.A.Chibrikova'), or initials closing one a
991 # title opens ('Dr.J.', 'Dr.J.R.'); never initials alone ('Kim
992 # Yu.A.', the surname-first initials #1254 asks about), an initial
993 # opening a chunk that stays whole ('Q.R.ST.'), nor a word holding a
994 # delimiter or a digit, which no chunk or name matches ('M.D./Ph.D.')
995 while True:
996 if _LETTER_RUN.fullmatch(text, at):
997 return tuple(cuts) if titled else ()
998 d = (dotted_title_end(low, at, dotted)
999 if dotted and low.startswith(dotted, at) else None)
1000 if d is not None and (
1001 (run := _GLUED_RUN.match(text, at)) is not None
1002 and run.end() > d):
1003 # a run of single letters running past the title is T7's
1004 # run of initials ('พ.ต.ส.สมชาย')
1005 d = None
1006 if d is not None:
1007 # a title listed with its stops is one chunk, read alone
1008 # (#1319: 'พล.ต.ต.สุรศักดิ์', 'พ.อ.หญิงวาสนา')
1009 titled = titled or not cuts
1010 if d == len(text):
1011 return tuple(cuts)
1012 nxt = _GLUED_CHUNK.match(text, d)
1013 if (low.startswith(dotted, d)
1014 and dotted_title_end(low, d, dotted) is not None) or (
1015 nxt is not None and is_title_chunk(nxt.group(1), lexicon)):
1016 cuts.append(d)
1017 at = d
1018 continue
1019 end, letter = d, False
1020 elif (run := _GLUED_RUN.match(text, at)) is not None:
1021 # a run of initials before the name stays one word (T7)
1022 end, letter = run.end(), True
1023 else:
1024 m = _GLUED_CHUNK.match(text, at)
1025 if m is None or not _reads_alone(m.group(1), lexicon):
1026 return ()
1027 end = m.end()
1028 letter = _is_cased_letter(
1029 unicodedata.normalize("NFC", m.group(1)))
1030 if not letter and is_title_chunk(m.group(1), lexicon):
1031 titled = titled or not cuts
1032 nxt = _GLUED_CHUNK.match(text, end)
1033 if nxt is not None and is_title_chunk(nxt.group(1), lexicon):
1034 # titles glued to each other divide at each stop, as
1035 # the titles written apart read ('Lt.Gov.John',
1036 # 'Maj.Gen.John', a phrase H9 reads)
1037 cuts.append(end)
1038 at = end
1039 continue
1040 word = _GLUED_WORD.match(text, end)
1041 # its first letter with case, so a modifier letter opening it
1042 # ('m.ʿali') does not hide a word in lower case; a script with
1043 # no case gives none, and divides
1044 if word is None or next(
1045 (c.islower() for c in word.group() if c.upper() != c.lower()),
1046 False):
1047 return ()
1048 initial = len(unicodedata.normalize("NFC", word.group())) == 1
1049 tail = text[word.end():]
1050 # a word closed by its own stop is a field or a name closed by
1051 # one ('Dr.Med.', 'J.Smith.'), save an initial behind a title
1052 # ('Dr.J.'); one followed by another chunk stays whole
1053 # ('Dr.John.Smith'), save an initial opening the next division
1054 # ('Yu.A.Chibrikova')
1055 if tail == "." and not (initial and not letter):
1056 return ()
1057 if len(tail) > 1 and not initial:
1058 return ()
1059 w = _normalize(word.group())
1060 if w in sets.titles or (
1061 w in sets.suffix_acronyms
1062 and w not in sets.suffix_acronyms_ambiguous) or (
1063 (w in sets.suffix_words or w in sets.credential_words)
1064 and w not in sets.suffix_words_ambiguous):
1065 return ()
1066 cuts.append(end)
1067 at = end
1068 if word.end() == len(text) or tail == ".":
1069 return tuple(cuts) if titled or not initial else ()
1070
1071
1072@functools.lru_cache(maxsize=16)
1073def _dotted_titles(titles: frozenset[str]) -> tuple[str, ...]:
1074 """The titles of a script without case listed with a full stop
1075 inside ('พ.ต.อ', 'พ.อ.หญิง'), longest first: one tuple per
1076 vocabulary, so a word opening with none of them is told by a C-level
1077 `startswith` alone. A cased title written with its stops ('dr.c',
1078 'mgr.a') is left out: glued, it keeps the reading decisions/T8's
1079 #1305 entry decided, a title and the initial behind it
1080 ('Dr.C.Meera'), where a caseless script writes no initials."""
1081 return tuple(sorted((t for t in titles if "." in t and t == t.upper()),
1082 key=len, reverse=True))
1083
1084
1085@functools.lru_cache(maxsize=16)
1086def title_prefixes(titles: frozenset[str]) -> tuple[str, ...]:
1087 """Every listed title, a tuple for a C-level `startswith`: what may
1088 stand behind a title only in a phrase glued in front (rules/W6)."""
1089 return tuple(titles)
1090
1091
1092def dotted_title_end(low: str, at: int,
1093 dotted: tuple[str, ...]) -> int | None:
1094 """Where a title listed with its stops, opening `low` at `at`, ends
1095 as a word of its own: past its own full stop, or, where its last
1096 chunk is more than one letter ('พ.อ.หญิง'), right before a letter
1097 of a script without case. A last chunk of one letter needs its
1098 stop, being the initial it spells otherwise ('น.สมชาย' is no
1099 'น.ส' + 'มชาย', 'พ.ต.อนุชา' no 'พ.ต.อ' + 'นุชา'), and a letter with
1100 case after a longer chunk goes on the word ('Lt.Colin', not 'Lt.Col'
1101 + 'in'); None where none does (#1319)."""
1102 for t in dotted:
1103 if not low.startswith(t, at):
1104 continue
1105 end = at + len(t)
1106 if end == len(low):
1107 return end
1108 if low[end] == ".":
1109 return end + 1
1110 c = low[end]
1111 last = t.rsplit(".", 1)[-1]
1112 if len(unicodedata.normalize("NFC", last)) > 1 and c.isalpha() and (
1113 c.upper() == c.lower()):
1114 return end
1115 return None
1116
1117
1118def glued_saint_cut(text: str, lexicon: Lexicon) -> int | None:
1119 """The offset after the stop where a listed saint particle is glued
1120 to the name it opens ('Sta.Ana', 'St.John', 'STE.MARIE'): the
1121 particle, one full stop and one name word whose first letter with
1122 case is a capital, nothing after it; None for any other word
1123 (rules/T8, #1071). The word is one name word wherever it stands,
1124 and divides here only as the last word of a part (`_script_segment`
1125 asks that)."""
1126 m = _GLUED_CHUNK.match(text)
1127 if m is None or _normalize(m.group(1)) not in lexicon._sets.saint_particles:
1128 return None
1129 word = _GLUED_WORD.fullmatch(text, m.end())
1130 if word is None or next(
1131 (c.islower() for c in word.group() if c.upper() != c.lower()),
1132 True):
1133 return None
1134 # an unambiguous title or suffix word after the stop is no name
1135 # ('St.MD', 'St.Jr')
1136 sets = lexicon._sets
1137 w = _normalize(word.group())
1138 if w in sets.titles or (
1139 w in sets.suffix_acronyms
1140 and w not in sets.suffix_acronyms_ambiguous) or (
1141 (w in sets.suffix_words or w in sets.credential_words)
1142 and w not in sets.suffix_words_ambiguous):
1143 return None
1144 return m.end()
1145
1146
1147def is_initial_chunk(chunk: str) -> bool:
1148 """Initials before a full stop, as T8's report asks of the chunks it
1149 cut (rules/T8): one letter in a script with case, a run of them
1150 ('J.R'), or a transliteration digraph rules/H2 names ('Yu'), and no
1151 title or given name written short."""
1152 parts = chunk.split(".")
1153 if len(parts) == 1 and _normalize(chunk) in _DIGRAPH_INITIALS:
1154 return True
1155 for part in parts:
1156 part = unicodedata.normalize("NFC", part)
1157 if len(part) != 1 or part.upper() == part.lower():
1158 return False
1159 return True
1160
1161
1162def is_title_chunk(chunk: str, lexicon: Lexicon) -> bool:
1163 """A listed title that is no saint particle, read as a chunk before
1164 a full stop (rules/T8; a saint particle glued to a name is one name
1165 word, `glued_saint_cut`)."""
1166 sets = lexicon._sets
1167 n = _normalize(chunk)
1168 return n not in sets.saint_particles and (
1169 n in sets.titles or n in sets.titles_ambiguous
1170 or n in sets.title_modifiers or n in sets.title_styles)
1171
1172
1173def is_dotted_title(text: str, n: str, lexicon: Lexicon) -> bool:
1174 """A run of single letters joined by full stops ('U.S.', 'P.D.')
1175 whose spelling without them is a title written `dotted`
1176 (`Lexicon.dotted_titles`, rules/H10, #490); `n` is the folded
1177 text, which both callers already have."""
1178 return (n.replace(".", "") in lexicon._sets.dotted_titles
1179 and _LETTER_RUN.fullmatch(text) is not None)
1180
1181
1182# #563: exactly two single letters, each closed by a period -- the way
1183# a given and a middle initial are written run together ('M.J.',
1184# 'G.J.'). The trailing period is optional, as `period_joined_vocab`'s
1185# shape verdict lets it be.
1186_PAIRED_INITIALS = re.compile(_LETTER + r"\." + _LETTER + r"\.?")
1187
1188
1189def is_paired_initials(text: str) -> bool:
1190 """Two single letters run together with periods ('M.J.'): the one
1191 unlisted dotted shape a person's own initials take, so after a
1192 comma it is a name until something in front of it, or another
1193 dotted word beside it, says it is a credential (rules/C5).
1194 Three or more letters ('X.Y.Z.') and any longer chunk ('B.Tech.')
1195 are outside it."""
1196 return _PAIRED_INITIALS.fullmatch(text) is not None
1197
1198
1199# rules/S3: "Paired initials are the exception, as C5 defines them, in a
1200# script that writes capitals" -- the script half of that clause, which
1201# classify asked inline until #752 (decisions/S3 (#864),
1202# decisions/vocabulary-model (#752)). C5's comma reading asks the bare
1203# `is_paired_initials`: after a comma a caseless pair reads as a name
1204# too ('John Smith, พ.บ.' given 'พ.บ.'), and moving it here would change
1205# that reading.
1206def is_cased_paired_initials(text: str) -> bool:
1207 """Paired initials (`is_paired_initials`) in a script that writes
1208 capitals: a text with a case mapping, so 'M.J.' and 'm.j.' but not
1209 Thai 'พ.บ.', which is how that script writes an abbreviation.
1210 The same compiled pattern as `is_paired_initials`, asked here
1211 rather than through it so a dotted token pays one frame, not
1212 two."""
1213 return (_PAIRED_INITIALS.fullmatch(text) is not None
1214 and text.upper() != text.lower())
1215
1216
1217# rules/S12: "a credential written split across a space reads as one
1218# word" -- one chunk of up to five letters, with or without its period
1219# ('Pharm.', 'D', 'Sc.'), the shape both words of a split credential
1220# take. The C-level half classify asks of every adjacent pair before
1221# the fold is looked up.
1222SPLIT_CHUNK = re.compile(r"(?:" + _LETTER + r"){1,5}\.?")
1223
1224
1225# mechanisms.md#ONE-PREDICATE-PER-QUESTION ("exactly one predicate answers it and every other site calls that one")
1226# -- the split credential's one test, asked by classify, which tags
1227# the pair for group's merge and `_comma._pieces`, and by
1228# `is_wholly_suffix` for the Ph./D. answer. decisions/phd-merge,
1229# decisions/vocabulary-model (#752)
1230def split_pair(first: str, second: str, lexicon: Lexicon) -> str | None:
1231 """Whether two adjacent words are one credential written split
1232 across a space, and so read as one unit -- answered with the PIECE
1233 TAG the merged pair carries, or None.
1234
1235 "suffix": 'Ph.' then 'D.', with or without their periods, in any
1236 case, where the lexicon lists `phd` as unambiguous suffix
1237 vocabulary (a credential, or a suffix word) -- v1's fix_phd,
1238 merged as an unambiguous suffix wherever it stands but the head
1239 of the string (decisions/phd-merge); under a lexicon that does
1240 not, None, the pair being the two words it is written as.
1241
1242 "split": any other pair of short chunks (`SPLIT_CHUNK`) whose
1243 period-free concatenation is unambiguous suffix vocabulary while
1244 the two are not both suffix vocabulary on their own ('Pharm. D.',
1245 'Pharm D', 'MB ChB' where 'chb' is unlisted) and neither is a
1246 particle or a connective, which join name words. The merged piece is
1247 a member of the ambiguous class, which the count decides (rules/S4,
1248 rules/C4). Its first chunk has two or more letters: a single
1249 letter opening the pair is a middle initial far more often (FEC
1250 writes 'SMITH, JOHN G. MR.', and 'G. MR.' folds to the listed
1251 'gmr'), so 'M. Sc.' and 'M. D.' stay two words. Two dotted single
1252 letters are the one opening letter asked further, and asked of
1253 #872's predicate rather than a copy: in a script with capitals
1254 they are paired initials (`is_cased_paired_initials`), the reading
1255 rules/S3 gives them joined, so only a caseless script's pair can
1256 merge, and not one in a script that writes no abbreviations (Han,
1257 hangul, kana: '博. 士.' is no dotted '博士'), which leaves no
1258 default entry such a fold.
1259
1260 Where the merge may happen (never at the head of the string,
1261 rules/S2; a member pair only in a main segment and in the part
1262 after a comma) is the caller's question, not this one's."""
1263 if _PH.fullmatch(first) is not None and _D.fullmatch(second) is not None:
1264 # rules/S12: "is a credential only where the lexicon lists `phd`"
1265 # -- as unambiguous suffix vocabulary of either kind, as the
1266 # joined spelling reads it; elsewhere the two words, the fold
1267 # below being `phd` itself, which an ambiguous suffix word would
1268 # admit as a member. decisions/phd-merge (#1000)
1269 sets = lexicon._sets
1270 return ("suffix" if "phd" in sets.suffix_acronyms
1271 and "phd" not in sets.suffix_acronyms_ambiguous
1272 or "phd" in sets.suffix_words
1273 and "phd" not in sets.suffix_words_ambiguous else None)
1274 if (SPLIT_CHUNK.fullmatch(first) is None
1275 or SPLIT_CHUNK.fullmatch(second) is None):
1276 return None
1277 a, b = first.rstrip("."), second.rstrip(".")
1278 if composed_len(a) < 2 and (
1279 composed_len(b) != 1 or not first.endswith(".")
1280 or not second.endswith(".")
1281 or is_cased_paired_initials(first + second)
1282 # a script that writes no abbreviations is not wearing an
1283 # abbreviation's periods, as rules/S3 reads it joined
1284 or in_initialless_script(first + second)):
1285 return None
1286 fold = _normalize(a + b)
1287 if (fold in lexicon._sets.suffix_acronyms_ambiguous
1288 or not (fold in lexicon._sets.suffix_acronyms
1289 or fold in lexicon._sets.suffix_words)):
1290 return None
1291 na, nb = _normalize(a), _normalize(b)
1292 if ((na in lexicon._sets.suffix_acronyms or na in lexicon._sets.suffix_words)
1293 and (nb in lexicon._sets.suffix_acronyms
1294 or nb in lexicon._sets.suffix_words)):
1295 return None
1296 # a particle or a connective joins name words, which is what it is
1297 # doing beside one ('DE A JUNIOR', the A a surname written short);
1298 # written with its period a letter is an initial, not the connective
1299 # it spells ('SAM E.', rules/T5)
1300 for written, n in ((first, na), (second, nb)):
1301 if not written.endswith(".") and (n in lexicon._sets.particles
1302 or n in lexicon._sets.conjunctions):
1303 return None
1304 return "split"
1305
1306
1307def suffix_as_written(n: str, text: str, lexicon: Lexicon) -> bool:
1308 """Counts as a suffix as written, with NO initial veto (the veto
1309 differs by caller): unambiguous suffix vocabulary, an ambiguous
1310 acronym written with periods ('M.A.' yes, 'Ma' no), or a credential
1311 joined by '-' or '/' (`_joined_credential`, 'MD/PhD'). `n` is
1312 _normalize(text), passed in so callers normalize once.
1313
1314 Single source for classify's "vocab:suffix" tag and the segment/
1315 assign predicates. The ambiguous subset is EXCLUDED from the plain
1316 membership test: in the real data suffix_acronyms_ambiguous is a
1317 subset of suffix_acronyms, and without the exclusion the period
1318 gate is dead code (bare 'Ed'/'Jd' would silently become suffixes).
1319 """
1320 # acronyms may be written with periods ('M.B.A.'): the ACRONYM
1321 # membership alone uses the period-free form (v1's is_suffix
1322 # removed periods only for the suffix_acronyms test); suffix WORDS
1323 # match on the plain normalized form
1324 a = n.replace(".", "")
1325 if a in lexicon._sets.suffix_acronyms_ambiguous and _dotted(text):
1326 return True
1327 if ((a in lexicon._sets.suffix_acronyms
1328 and a not in lexicon._sets.suffix_acronyms_ambiguous)
1329 or (n in lexicon._sets.suffix_words
1330 and n not in lexicon._sets.suffix_words_ambiguous)):
1331 return True
1332 # the joiners asked in C first, so a word holding neither pays no
1333 # frame
1334 return ("-" in n or "/" in n) and _joined_credential(text, lexicon)
1335
1336
1337#: What joins the letter chunks of the caps shape's joined spelling
1338#: ('PU-PH', `_joined_capitals`); a joined credential is cut by
1339#: `_JOINER_SPLIT`, which keeps the joiners.
1340_JOINERS = re.compile(r"[-/]")
1341
1342
1343def _member(chunk: str, lexicon: Lexicon) -> bool:
1344 """Whether CHUNK is a member of the ambiguous class (S4), however
1345 written ('MA', 'Ma', 'M.A', 'M.A.'): an acronym once its periods are
1346 removed, or a suffix word, that is a name too."""
1347 folded = _normalize(chunk)
1348 return (folded.replace(".", "") in lexicon._sets.suffix_acronyms_ambiguous
1349 or folded in lexicon._sets.suffix_words_ambiguous)
1350
1351
1352def _settles(chunk: str, lexicon: Lexicon) -> bool:
1353 """Whether CHUNK, one word or one listed run of a joined word's cut,
1354 settles a member beside it: a credential that is no name
1355 (`settles_a_member`), or a member written with a period inside it
1356 ('M.A')."""
1357 return (settles_a_member(chunk, lexicon)
1358 or "." in chunk and dotted_member(chunk, lexicon))
1359
1360
1361# rules/S1: "a credential that is no name, or a member its periods
1362# settle, says which" -- the credential half, asked of a clause word
1363# and of a joined word's chunk alike: a generation, a numeral, an
1364# honorific or a word that is a title too says nothing of the kind,
1365# 'Ed Jr' being a name a person goes by (decisions/S1, #1138)
1366def settles_a_member(word: str, lexicon: Lexicon) -> bool:
1367 """Whether WORD, a comma it is written against left off (rules/N1),
1368 is a credential that is no name: it counts as a suffix as written
1369 (a member its periods settle, 'M.A.', included) and is no
1370 generation, no honorific and no title. A joined word not listed
1371 whole is one where a chunk of its cut is ('PhD/Jr', not 'Jr/Sr')."""
1372 bare = word.strip(COMMA_STRIP)
1373 if ("/" in bare or "-" in bare) and not _listed_whole(bare, lexicon):
1374 return any(_settles(c, lexicon)
1375 for c in _joined_chunks(bare, lexicon) or ())
1376 folded = _normalize(bare)
1377 sets = lexicon._sets
1378 return (suffix_as_written(folded, bare, lexicon)
1379 and folded not in sets.generation_words
1380 and folded not in sets.honorific_words
1381 and folded not in sets.titles)
1382
1383
1384def joined_member(word: str, lexicon: Lexicon) -> bool:
1385 """Whether WORD, joined by '/' or '-' and not listed whole ('NP-C'),
1386 cuts (`_joined_chunks`) into pieces one of which is a member of the
1387 ambiguous class that does not count as a suffix as written
1388 ('MA/PhD', 'M.A/PhD', 'MA/MBA'; not 'M.A./PhD'). `set_apart_kind`
1389 asks it of a word `suffix_as_written` reads, making that word a
1390 member S13 settles by its position; `_report_delimited_members`
1391 asks it of any joined nickname, so a joined word of members alone
1392 reports as its words written apart do."""
1393 if not ("/" in word or "-" in word) or _listed_whole(word, lexicon):
1394 return False
1395 return any(_member(c, lexicon)
1396 and not suffix_as_written(_normalize(c), c, lexicon)
1397 for c in _joined_chunks(word, lexicon) or ())
1398
1399
1400def member_word(word: str, lexicon: Lexicon) -> bool:
1401 """Whether a word of a delimited clause, a comma the clause keeps
1402 on it left off (rules/N1), is a member of the ambiguous class (S4),
1403 however written."""
1404 return _member(word.strip(COMMA_STRIP), lexicon)
1405
1406
1407def _listed_whole(word: str, lexicon: Lexicon) -> bool:
1408 """Whether the suffix vocabulary lists WORD whole, its joiners
1409 included ('NP-C', 'OTR/L'): as a suffix word, or as an acronym once
1410 its periods are removed. A member of an ambiguous class counts as
1411 listed."""
1412 folded = _normalize(word)
1413 return (folded in lexicon._sets.suffix_words
1414 or folded.replace(".", "") in lexicon._sets.suffix_acronyms)
1415
1416
1417#: The joiners of a joined credential, kept: 'MS/OTR/L' splits to
1418#: 'MS', '/', 'OTR', '/', 'L', the chunks at the even places.
1419_JOINER_SPLIT = re.compile(r"([-/])")
1420
1421
1422# bounded as `_longest_marker` is, for the reason given there: a set
1423# per lexicon, so 16 bounds many-lexicon churn without evicting in
1424# normal use
1425@functools.lru_cache(maxsize=16)
1426def _joined_span(words: frozenset[str]) -> int:
1427 """How many chunks the most-joined of WORDS holds: the longest run a
1428 listed word can be. Cached per set, which hashes once."""
1429 return max((w.count("/") + w.count("-") for w in words), default=0) + 1
1430
1431
1432# rules/S3: "a run of chunks the vocabulary lists whole, its slash or
1433# hyphen included, is one credential" -- the joiner inside 'NP-C' or
1434# 'OTR/L' is how that credential is spelled, not a joiner between two
1435# (decisions/S3, #1103)
1436def _joined_chunks(text: str, lexicon: Lexicon) -> list[str] | None:
1437 """A cut of TEXT at some of its joiners into pieces that each count
1438 as a suffix as written or are a member of the ambiguous class
1439 however written ('MA', 'M.A'), a run of chunks the vocabulary lists
1440 whole among them, with the fewest pieces ('MS/OTR/L' is 'MS', 'OTR/L';
1441 'CCC/SLP/L' is 'CCC', 'SLP/L'; 'APRN-FNP-C' is 'APRN', 'FNP-C';
1442 'MD-PhD' is 'MD', 'PhD'), or None where no cut gives such pieces
1443 ('MD-Jones', 'MD/'). No piece is the whole of TEXT: each caller has
1444 asked TEXT whole already, and a word listed whole that it refused
1445 (one that is a name too) would otherwise be asked again for ever.
1446 A piece is no longer than the most-joined listed word, so a long
1447 chain of joiners costs linear time."""
1448 parts = _JOINER_SPLIT.split(text)
1449 n = (len(parts) + 1) // 2 # the chunks
1450 span = max(_joined_span(lexicon._sets.suffix_words),
1451 _joined_span(lexicon._sets.suffix_acronyms))
1452 # pieces[k]: the fewest pieces cutting the first k chunks (0 where
1453 # none does, past the start); start[k]: where the last of them starts
1454 pieces = [0] * (n + 1)
1455 start = [0] * (n + 1)
1456 for i in range(n):
1457 if i and not pieces[i]:
1458 continue
1459 for j in range(i + 1, min(n - (i == 0), i + span) + 1):
1460 piece = "".join(parts[2 * i:2 * j - 1])
1461 # a run must be listed whole, which a chunk alone need not be
1462 if not piece or j > i + 1 and not _listed_whole(piece, lexicon):
1463 continue
1464 # a member is a chunk however written; `_joined_credential`
1465 # asks that a chunk beside it settles it (#1138)
1466 if ((not pieces[j] or pieces[i] + 1 < pieces[j])
1467 and (suffix_as_written(_normalize(piece), piece, lexicon)
1468 or _member(piece, lexicon))):
1469 pieces[j] = pieces[i] + 1
1470 start[j] = i
1471 if not pieces[n]:
1472 return None
1473 cut: list[str] = []
1474 j = n
1475 while j:
1476 i = start[j]
1477 cut.append("".join(parts[2 * i:2 * j - 1]))
1478 j = i
1479 cut.reverse()
1480 return cut
1481
1482
1483# rules/S3: "a word whose chunks are joined by a hyphen or a slash
1484# reads as a suffix when every chunk counts as a suffix as written" --
1485# the stricter test, every chunk rather than any, because a hyphen also
1486# joins names (decisions/S3 (#752)); a chunk is a run the vocabulary
1487# lists whole where one is (#1103)
1488def _joined_credential(text: str, lexicon: Lexicon) -> bool:
1489 """Whether TEXT is two or more chunks joined by '-' or '/', cut so
1490 that each is non-empty and counts as a suffix as written (listed,
1491 and not a bare member of the ambiguous class, a run listed whole
1492 counting as one chunk) or is a member of that class however
1493 written, where one chunk at least settles every such member
1494 (`_settles`), and not every chunk a single ASCII character, and --
1495 joined by a hyphen -- not written as a hyphenated name is ('MD/PhD', 'M.A.-Ph.D.', 'MA/PhD',
1496 'M.A/PhD', 'PhD-Md', 'md-phd', 'PhD/NP-C', but not 'MA/MBA',
1497 'MA/Jones', 'Smith-Jones', 'I-V', 'Ae-Ra', 'Ae-ra' or 'MD/'). The
1498 writing is read first, so a hyphenated name ('Smith-Jones') is
1499 never cut."""
1500 # a slash joins no name, so a word holding one takes no cue
1501 if "/" not in text:
1502 # rules/S3: "a word joined by a hyphen and written the way a
1503 # hyphenated name is, its first chunk in Title case and every
1504 # other in Title case or lower case, is a name word" -- where a
1505 # credential's chunks are not (decisions/S3 (#752)); its periods
1506 # are not the chunk's ('I.' is 'i'), as in a word split at its
1507 # periods
1508 named = True
1509 for k, c in enumerate(text.split("-")):
1510 bare = c.replace(".", "")
1511 named = (len(bare) > 1 and bare[0].isupper() and bare[1:].islower()
1512 or k > 0 and bare.islower())
1513 if not named:
1514 break
1515 if named:
1516 return False
1517 chunks = _joined_chunks(text, lexicon)
1518 if chunks is None:
1519 return False
1520 # rules/S3: "a member of the ambiguous class (S4) is a chunk too,
1521 # however it is written, where the word holds a chunk that settles
1522 # it" -- a credential that is no name, or a member its periods
1523 # settle; a word of bare members alone has nothing to settle it
1524 # (decisions/S3, #1138)
1525 unsettled = any(_member(c, lexicon)
1526 and not suffix_as_written(_normalize(c), c, lexicon)
1527 for c in chunks)
1528 if unsettled and not any(_settles(c, lexicon) for c in chunks):
1529 return False
1530 # rules/S3: "Every chunk the vocabulary matches is a single ASCII
1531 # character" -- the numerals and the digit, which are about
1532 # generations, make no joined word a suffix either ('Jack I-V')
1533 for c in chunks:
1534 bare = c.replace(".", "")
1535 if len(bare) > 1 or not bare.isascii():
1536 return True
1537 return False
1538
1539
1540# rules/S3: "a word with interior periods reads as a suffix when
1541# every one of its period-separated chunks counts as a suffix as
1542# written" -- the joined words' every chunk, the periods being the one
1543# sign the word is joined at all (decisions/S3, #1131)
1544def period_joined_credential(text: str, lexicon: Lexicon) -> bool:
1545 """Whether TEXT, cut at its periods (`_period_chunks`), is chunks
1546 that each count as a suffix as written or are a member of the ambiguous
1547 class, one chunk at least settling every such member (`_settles`),
1548 and not every chunk the vocabulary matches a single ASCII character
1549 ('Msc.Ed', 'JD.CPA'; not 'Bob.PhD', 'Ma.Ed' or 'X.Y.I.'). A word
1550 whose last chunk is a glued honorific ('J.씨') reads as one too:
1551 the honorific is the suffix there and the chunk before it the
1552 initial it is written as (rules/W2). The chunks are two or more,
1553 save one where the word's periods all stand at its edge beside an
1554 ampersand or a semicolon ('MD.&', as 'MD.& PhD' writes it)."""
1555 chunks = _period_chunks(text)
1556 if chunks is None:
1557 return False
1558 sets = lexicon._sets
1559 if _normalize(chunks[-1]) in sets.honorific_tails:
1560 return True
1561 unsettled = False
1562 for c in chunks:
1563 folded = _normalize(c)
1564 if suffix_as_written(folded, c, lexicon):
1565 continue
1566 # rules/S11: "the word is part of that one word and is weighed
1567 # only by the shape the joined word wears" -- no chunk claim
1568 # for a suffix word that is also a name ('MD.Filho', whose Filho
1569 # MD would otherwise settle as a member)
1570 if folded in sets.suffix_words_ambiguous or not _member(c, lexicon):
1571 return False
1572 unsettled = True
1573 if unsettled and not any(_settles(c, lexicon) for c in chunks):
1574 return False
1575 # rules/S3: "Every chunk the vocabulary matches is a single ASCII
1576 # character" -- a numeral or the digit says nothing of a credential
1577 return not all(len(c) == 1 and c.isascii() for c in chunks)
1578
1579
1580def _period_chunks(text: str) -> list[str] | None:
1581 """TEXT cut at its periods, or None where it holds none inside it
1582 (one chunk where every period stands at the word's edge and an '&'
1583 or ';' there keeps one off the outer end: 'MD.&', ';.MD').
1584 ASCII punctuation beside an interior period is no part of the chunk
1585 ('MD.&PhD'); at the word's outer edges only its periods, a glued
1586 semicolon (rules/C10, 'Jr.;') and an ampersand joining the next
1587 credential ('MD.& PhD') are, so a word wearing any other there
1588 is no period-joined word at all, as a single word or a slash word
1589 wearing it is a name word ('Msc.Ed!', 'Msc.Ed.]', '[Msc.Ed]' as
1590 'MD!', 'MD/PhD]'). A chunk of nothing but punctuation is no
1591 chunk."""
1592 if "." not in text.strip("."):
1593 return None
1594 i, j = 0, len(text)
1595 while i < j and text[i] in string.punctuation:
1596 i += 1
1597 while j > i and text[j - 1] in string.punctuation:
1598 j -= 1
1599 if any(c not in ".;&" for c in text[:i] + text[j:]):
1600 return None
1601 chunks = [b for c in text[i:j].split(".")
1602 if (b := c.strip(string.punctuation))]
1603 return chunks or None
1604
1605
1606def _holds_a_member(text: str, lexicon: Lexicon) -> bool:
1607 """Whether a chunk of TEXT cut at its periods (`_period_chunks`) is
1608 a member of the ambiguous class that does not count as a suffix as
1609 written ('Ed' in 'Msc.Ed')."""
1610 return any(_member(c, lexicon)
1611 and not suffix_as_written(_normalize(c), c, lexicon)
1612 for c in _period_chunks(text) or ())
1613
1614
1615def period_member(word: str, lexicon: Lexicon) -> bool:
1616 """Whether WORD, a word with interior periods that is no acronym
1617 listed once its periods are removed, is
1618 a period-joined credential (`period_joined_credential`) holding a
1619 member of the ambiguous class ('Msc.Ed'): `joined_member`'s
1620 question for a word the periods join (rules/S3, #1131)."""
1621 bare = word.strip(COMMA_STRIP)
1622 folded = _normalize(bare)
1623 return ("." in folded
1624 and folded.replace(".", "") not in lexicon._sets.suffix_acronyms
1625 and period_joined_credential(bare, lexicon)
1626 and _holds_a_member(bare, lexicon))
1627
1628
1629# rules/H5: "a word with interior periods every one of whose chunks is
1630# a title chains without a final period" -- 'Lt.Gov', where the
1631# periods join nothing but titles (decisions/H5, #1131)
1632def period_joined_title(text: str, lexicon: Lexicon) -> bool:
1633 """Whether TEXT, cut at its periods, is two or more chunks every
1634 one of which is title vocabulary ('Lt.Gov', 'Lt.Col'; not
1635 'Dr.PhD' or 'St.John')."""
1636 chunks = [_normalize(c) for c in text.split(".") if c]
1637 return len(chunks) >= 2 and all(
1638 c in lexicon._sets.titles for c in chunks)
1639
1640
1641def _is_suffix_strict_n(n: str, text: str, lexicon: Lexicon,
1642 one_case: bool | None = None) -> bool:
1643 # rules/T4: "in a name written wholly in one case, which says
1644 # nothing about any letter in it, a bare letter is an initial
1645 # wherever its capital would be" -- so the veto takes 'v' where it
1646 # takes 'V' (#727). Lower case first, at C level: a capital is
1647 # is_initial's already, and only a caller with the fact passes it
1648 if is_initial(text) or (
1649 one_case and len(text) == 1 and text.islower()
1650 and _INITIAL.fullmatch(text.upper()) is not None):
1651 # period-written ambiguous acronyms are exempt from the veto
1652 return _dotted(text) and \
1653 n.replace(".", "") in lexicon._sets.suffix_acronyms_ambiguous
1654 return suffix_as_written(n, text, lexicon)
1655
1656
1657def is_suffix_strict(text: str, lexicon: Lexicon,
1658 one_case: bool | None = None) -> bool:
1659 """v1's is_suffix: suffix_as_written with the initial veto ('V.' in
1660 'John V. Smith' is a middle initial, not roman five).
1661
1662 `one_case` is whether the name's own words are written in one
1663 case (ParseState.one_case): where they are, a bare lowercase
1664 letter is the initial its capital is (rules/T4) and is vetoed as
1665 its capital is. None or False is the veto by the writing alone,
1666 which is v1's."""
1667 return _is_suffix_strict_n(_normalize(text), text, lexicon, one_case)
1668
1669
1670def is_suffix_lenient(text: str, lexicon: Lexicon) -> bool:
1671 """v1's is_suffix_lenient: suffix_words accepted unconditionally,
1672 bypassing the initial veto -- only safe in unambiguous positions
1673 (after a comma)."""
1674 n = _normalize(text)
1675 return (n in lexicon._sets.suffix_words
1676 and n not in lexicon._sets.suffix_words_ambiguous) \
1677 or _is_suffix_strict_n(n, text, lexicon)
1678
1679
1680#: The suffix delimiters every policy has, beside the comma. rules/C10:
1681#: "The semicolon, the hyphen and the slash are delimiters by default"
1682#: -- "no policy removes them, as none removes the comma", so a
1683#: policy's extra_suffix_delimiters add to them. decisions/C10 (D15,
1684#: #889).
1685BUILTIN_SUFFIX_DELIMITERS = frozenset({";", "-", "/"})
1686
1687#: The built-in delimiters read glued to a word or inside one. rules/C10:
1688#: "a built-in hyphen or slash parts only standing alone" -- glued, it
1689#: is the word's own ('al-', 'MD/PhD'). decisions/C10 (#889).
1690BUILTIN_GLUED_DELIMITERS = frozenset({";"})
1691
1692
1693def delimiter_cores(policy_delimiters: frozenset[str]) -> frozenset[str]:
1694 """The built-in suffix delimiters with the configured ones,
1695 surrounding whitespace stripped: ' - ' -> '-', the cores that part
1696 a trailing suffix part STANDING ALONE. Whitespace-padded
1697 delimiters surface as standalone tokens; the stripped core is what
1698 tokenize produced."""
1699 if not policy_delimiters:
1700 return BUILTIN_SUFFIX_DELIMITERS
1701 return BUILTIN_SUFFIX_DELIMITERS | frozenset(
1702 d.strip() for d in policy_delimiters if d.strip())
1703
1704
1705def glued_cores(policy_delimiters: frozenset[str]) -> frozenset[str]:
1706 """The cores read glued to one end of a word or inside one: the
1707 semicolon and every configured core, never a built-in hyphen or
1708 slash that the caller did not declare (BUILTIN_GLUED_DELIMITERS).
1709 A configured core that is itself a word (' and ') is in the set:
1710 `glued_stem`'s callers ask `glues` before reading it glued, while
1711 `is_wholly_suffix`'s in-word split does not ('MDandPhD')."""
1712 if not policy_delimiters:
1713 return BUILTIN_GLUED_DELIMITERS
1714 return BUILTIN_GLUED_DELIMITERS | frozenset(
1715 d.strip() for d in policy_delimiters if d.strip())
1716
1717
1718# rules/C10: "the word keeps the delimiter in its text and reads as it
1719# would without it" -- the word, for every site that reads one: the
1720# tags classify writes, the comma decision's shape tests, C2's flag
1721# and the run test below (decisions/C10).
1722def glued_stem(text: str, cores: frozenset[str]) -> str | None:
1723 """The word a delimiter core glued to ONE end of `text` leaves
1724 ('MD;' -> 'MD', ';PhD' -> 'PhD', 'MD-PhD;' -> 'MD-PhD' under '-'),
1725 or None: no core at an end of the text, a core standing alone, one
1726 inside a word only ('MD;PhD', which splits_into_suffixes reads), or
1727 a core that is itself a word (see glues). Every core is tried, in
1728 sorted order, so the answer never depends on a set's iteration
1729 order (the second review of #871: 'MD/PhD; MBA' under '/' read two
1730 ways by hash seed). Callers test `core in text` inline first, so a
1731 word with no core pays no frame."""
1732 for core in sorted(cores):
1733 if (core in text and glues(core) and text.count(core) == 1
1734 and len(text) > len(core)):
1735 if text.endswith(core):
1736 return text[:-len(core)]
1737 if text.startswith(core):
1738 return text[len(core):]
1739 return None
1740
1741
1742def glues(core: str) -> bool:
1743 """Whether a delimiter core can be GLUED to a word: one written
1744 without a letter or digit (';', '/', '-'). A core that is a word
1745 (' and ' under a declared policy) is found inside other words by
1746 the substring test ('Stand'), so it parts only standing alone
1747 (rules/C10's glued bullet)."""
1748 return not any(ch.isalnum() for ch in core)
1749
1750
1751def splits_into_suffixes(text: str, cores: frozenset[str],
1752 lexicon: Lexicon) -> bool:
1753 """v1 expand_suffix_delimiter parity for delimiters WITHOUT
1754 whitespace ('RN/CRNA' with '/'): the token counts as a suffix when
1755 some core splits it into >=2 non-empty parts that are all suffixes.
1756 A core glued to ONE word ('MD;') is is_wholly_suffix's to read, by
1757 the test it gives that word alone (D15). The token text is never
1758 rewritten (anti-#100): it takes Role.SUFFIX whole, which renders
1759 'RN/CRNA' where v1 rendered 'RN, CRNA' -- the documented
1760 divergence, release-log classified."""
1761 for core in cores:
1762 if core in text:
1763 parts = [part for part in text.split(core) if part]
1764 if len(parts) >= 2 and all(
1765 is_suffix_lenient(part, lexicon) for part in parts):
1766 return True
1767 return False
1768
1769
1770# rules/S3: "a word with interior periods reads as a suffix when
1771# every one of its period-separated chunks counts as a suffix as
1772# written" -- "every chunk the vocabulary matches is a single ASCII
1773# character, the roman numerals and the lone digit the vocabulary
1774# lists, which are about generations rather than credentials". The
1775# chunk rule and its single-character exception (decisions/S3,
1776# #1131).
1777def period_joined_vocab(
1778 text: str, lexicon: Lexicon,
1779) -> Literal["title", "suffix", "shape"] | None:
1780 """v1's parse_pieces derivation for interior-period tokens
1781 ('Lt.Gov.', 'Msc.Ed.', and by the ANY rule 'Mr.Smith'): ANY title
1782 chunk makes the token a title (checked first, v1's continue); else
1783 the token is a suffix where EVERY chunk counts
1784 (`period_joined_credential`, #1131: 'Msc.Ed.', whose member 'ed'
1785 'msc' settles, but not 'Bob.PhD', which the shape verdict below
1786 reads by position). A suffix word that is also a name is a member
1787 there, settled by a credential beside it ('M.Filho' is no suffix,
1788 rules/S11). Returns "title", "suffix", "shape", or None.
1789
1790 "shape" is the third verdict and it is the absence of a claim
1791 worth acting on (#516): two or more chunks, EVERY one of them
1792 alphabetic, that the every-chunk test does not read as a suffix
1793 ('Bob.PhD', 'BM.BS', #1131), chunks that are a SINGLE ASCII
1794 CHARACTER among them. That single-character exception is the
1795 roman-numeral accident retired narrowly: measured 2026-09-15 the
1796 one-character suffix vocabulary is {'2', 'i', 'v'} plus the glued
1797 CJK honorific tails, so 'John Smith R.A.I.' was reading as a
1798 generational suffix off the chunk 'i'. CHARACTER rather than
1799 LETTER because '2' is a digit and is in the set; ASCII because
1800 '씨' is the single character that must KEEP its claim ('J.씨'). A
1801 word whose every chunk counts still reads as a suffix, so 'Msc.Ed.'
1802 and 'JD.CPA' are untouched -- the WIDE retirement, where
1803 any chunk match yields to the shape, was measured and rejected: it
1804 costs 'Doe, John Msc.Ed.' a real credential and re-routes the
1805 honorific peel of '김민준씨, J.씨' for nothing this design wants
1806 (decisions/S3).
1807
1808 The ALPHABETIC requirement is a SEPARATE, narrower gate on the
1809 shape verdict alone (#516 review round, decided by the
1810 orchestrator): a bare digit chunk is not an acronym letter by any
1811 reading, so 'Smith, 1.4' and 'John Smith 1.4' do not join the
1812 class by shape -- the only PROTECTED digit control this design
1813 had was the delimited 'Bridge (1.4)', and 'Smith, 1.4' and its
1814 no-comma twin were the gap that control did not cover, since
1815 delimited content is excluded by a different mechanism (extract's
1816 escape) and digits reach THIS detector, never that one.
1817 '.isalpha()' is asked of EVERY chunk, not just the matched ones,
1818 so a mixed token like 'X.Y.2.' is not shape-admitted either -- one
1819 non-letter chunk is enough to say the writing is not spelling an
1820 acronym. Chunks the vocabulary itself matches are already
1821 alphabetic in the shipped lexicon, so this narrows only the
1822 previously-unclaimed "shape" answer, never the "suffix" one.
1823
1824 The INITIALLESS-SCRIPT guard is a second, independent narrowing of
1825 the same verdict, on the same reasoning `is_title_shaped` already
1826 gives its own period-abbreviation inference (#323): a script with
1827 no period abbreviations at all has nothing for interior periods to
1828 ABBREVIATE, so a CJK word glued into period-separated single
1829 characters is not spelling an acronym either -- 'John Smith
1830 田.中.' and '김 민준 이.박.' stayed family at every release before
1831 this gate existed, and would otherwise have joined the ambiguous
1832 class by shape for the first time (measured regression, #516
1833 review round).
1834
1835 What a "shape" verdict MEANS is the caller's question, not this
1836 one's: classify writes the tag, and Policy.unlisted_dotted_suffixes
1837 decides whether it is class membership.
1838 """
1839 if not _PERIOD_NOT_AT_END.match(text):
1840 return None
1841 # rules/T8: "a saint particle glued to a name is one name word" --
1842 # no title chunk claims it and no dotted shape reads it
1843 # (decisions/T8 (#1071))
1844 if glued_saint_cut(text, lexicon) is not None:
1845 return None
1846 chunks = [_normalize(c) for c in text.split(".") if c]
1847 if any(c in lexicon._sets.titles for c in chunks):
1848 return "title"
1849 if period_joined_credential(text, lexicon):
1850 return "suffix"
1851 if (len(chunks) >= 2 and all(c.isalpha() for c in chunks)
1852 and (text.isascii() or not in_initialless_script(text))):
1853 return "shape"
1854 return None
1855
1856
1857def ambiguous_class_member(text: str, lexicon: Lexicon) -> bool:
1858 """Whether TEXT is a member of the LISTED ambiguous credential
1859 class, ignoring case and policy entirely (#289/#516).
1860
1861 The case-INDEPENDENT half of the comma form's own candidate test
1862 (`ambiguous_class_candidate`, below, which ALSO admits a by-shape
1863 member where Policy allows it) and of `is_wholly_suffix`'s
1864 credential-lean disjunct: membership by vocabulary never needs the
1865 case fact, only the lean does. That split is what lets the comma
1866 form's lazy gate ask membership FIRST and pay for `one_case` only
1867 where this says yes (measured regression, #289/#516: forcing the
1868 fact first ran `own_words` -> `tag_marker_runs` for every
1869 single-token-after-the-comma name, none of them able to reach the
1870 class at all).
1871
1872 Membership is the listed set, bare: a whole-token vocabulary match
1873 is not in this class at all, being settled ('M.A.', 'Ph.D.',
1874 'A.B.C.').
1875
1876 Cheaper than `suffix_as_written(n, text, lexicon) or ...` would be
1877 here, and provably the same answer for UNDOTTED text: the Lexicon
1878 invariant that an ambiguous acronym is never also a suffix WORD
1879 (`__post_init__`'s gate_bypassed check) and is never counted
1880 without periods once it IS one (`suffix_as_written`'s own
1881 exclusion) together kill that predicate's two disjuncts once text
1882 is known to hold no period. One frame (`_normalize`) on the common
1883 path that never reaches the class, where the general predicate
1884 cost at least two (mechanisms.md#ONE-PREDICATE-PER-QUESTION's cost
1885 clause).
1886
1887 The '.' gate here is DELIBERATELY STRICTER than S2's own
1888 dotted-form test (`_dotted`, a period after EACH letter or after
1889 each of two or more letter chunks): '.'
1890 anywhere excludes membership, so a single TRAILING period ('MA.',
1891 'Ed.') is excluded here even though the LEAN still reads it as
1892 the bare acronym's case ('Smith, MA.' -> suffix 'MA.', measured).
1893 Not a contradiction: the two questions this answers -- the
1894 comma-form CANDIDATE test below and the credential-lean disjunct
1895 -- are never asked of those shapes, the TAG path
1896 (`vocab:suffix-ambiguous`, read directly by the trailing peel and
1897 the post-comma slot) and H2's leading-title shape test already
1898 carrying them where they need to go.
1899 """
1900 if "." in text:
1901 return False
1902 n = _normalize(text)
1903 return (n in lexicon._sets.suffix_acronyms_ambiguous
1904 or n in lexicon._sets.suffix_words_ambiguous)
1905
1906
1907# #516's all-caps half, ONE PREDICATE for the shape test and its
1908# WHOLE-VOCABULARY exclusion, shared by the three sites that each
1909# needed the identical question answered (classify's tag emission,
1910# the comma decision's caps test -- `_segment.py`'s run test until #613
1911# moved the decision to `_comma.decide` -- and this module's own unit
1912# tests), where it had been spelled three times over (quality-review
1913# finding). The usual objection to sharing -- a call costing every
1914# default-policy parse a frame it cannot use -- does not apply: the
1915# trailing position is behind `CapsSuffixes.EVERYWHERE` (classify's
1916# first conjunct), and the comma decision, which IS on by default,
1917# asks C-level `isalpha()`/`isupper()` and the suffix tag first, so a
1918# comma name with no unlisted all-caps word never reaches it.
1919def caps_shape_candidate(text: str, lexicon: Lexicon, policy: Policy,
1920 one_case: bool | None) -> bool:
1921 """Whether TEXT is an UNLISTED all-caps credential candidate: two
1922 or more alphabetic characters, or letter chunks joined by '-' or
1923 '/' (`_joined_capitals`, #752), no period (a period fails
1924 `isalpha()` and the joined pattern outright, which is what excludes
1925 the dotted spelling here for free -- no separate '.' test needed),
1926 written in a name
1927 `one_case` says is mixed (`one_case is False`; `None`, "not
1928 established", declines exactly as if the name were one case).
1929
1930 UNLISTED means in NO wordlist at all, not merely "no whole-token
1931 suffix vocabulary", and the roster is `_lexicon._VOCAB_FIELDS`
1932 itself rather than a list written out here -- checked directly
1933 against the lexicon rather than through a caller's tags (classify
1934 is deciding the very tag a caller would read, and `_comma.decide`
1935 asks of words classify left untagged). A hand-written roster is a second place to remember,
1936 and it had already gone wrong: it named eleven of the thirteen
1937 fields, leaving `surnames` and `honorific_tails` out, so
1938 a `Lexicon` whose `surnames` listed "dupont" still read
1939 `Jean Pierre DUPONT` as suffix `DUPONT` -- a caller listing a word
1940 as a SURNAME and getting it read as a credential is this switch's
1941 own worst failure, arriving through the one wordlist that says
1942 "this is a family name" (review round, #289/#516;
1943 `honorific_tails` was already excluded transitively, being a
1944 subset of `suffix_words` by Lexicon invariant, and joins the
1945 roster for completeness rather than for a behavior change).
1946 Measured, #516 review rounds: particles, ambiguous particles,
1947 conjunctions, bound-given heads, a maiden marker ('NEE'/'GEB') and
1948 a title all join the shape by capitalization alone if their field
1949 is left unchecked.
1950
1951 `suffix_acronyms_ambiguous` is in the roster, which also makes
1952 this predicate stand in for `ambiguous_class_member` wherever a
1953 caller needs "and not already a LISTED member" (undotted text's
1954 only path into that function is the identical membership test);
1955 its callers still test the listed set first, in C (classify's
1956 `n not in suffix_acronyms_ambiguous`, `_comma.decide`'s
1957 AMBIGUOUS_ACRONYM_TAG test), so a listed member never pays for
1958 the call.
1959 """
1960 if (policy.unlisted_caps_suffixes is CapsSuffixes.OFF
1961 or one_case is not False):
1962 return False
1963 if not text.isascii():
1964 text = unicodedata.normalize("NFC", text)
1965 if not (len(text) >= 2 and text.isalpha() and text.isupper()
1966 or _joined_capitals(text)):
1967 return False
1968 return not in_any_wordlist(_normalize(text), lexicon)
1969
1970
1971#: A word of letter chunks joined by '-' or '/' ('PU-PH', 'MD/MBA').
1972_JOINED_LETTERS = re.compile(
1973 r'(?:' + _LETTER + r')+(?:[-/](?:' + _LETTER + r')+)+')
1974
1975
1976# rules/S9: "an unlisted word of capitals whose letter chunks are joined
1977# by a hyphen or a slash, one of them of two or more letters, is that
1978# shape too" -- the caps shape's joined spelling (decisions/S9 (#752))
1979def _joined_capitals(text: str) -> bool:
1980 """Whether TEXT is capitals in letter chunks joined by '-' or '/',
1981 one chunk of two or more letters ('PU-PH', 'MS-OMPT', not 'A-B',
1982 'MD/' or 'PU-PH2'). Its callers test `isupper()` and a joiner in C
1983 first, so a word holding neither pays no frame."""
1984 if not (text.isupper() and _JOINED_LETTERS.fullmatch(text)):
1985 return False
1986 for chunk in _JOINERS.split(text):
1987 if composed_len(chunk) >= 2:
1988 return True
1989 return False
1990
1991
1992def is_caps_word(text: str) -> bool:
1993 """Whether TEXT is alphabetic capitals once NFC-composed: `isalpha()`
1994 is False for a combining mark, so the decomposed spelling of
1995 'ŽŠČ' would otherwise not be a caps word (#652). Callers test the
1996 text as written inline first, in C, and call this only for text
1997 that is not NFC (`unicodedata.is_normalized`, also C), so composed
1998 text pays no frame."""
1999 composed = unicodedata.normalize("NFC", text)
2000 return composed.isalpha() and composed.isupper()
2001
2002
2003def composed(text: str) -> str:
2004 """TEXT NFC-composed where it is not ASCII, so a letter typed as its
2005 base and a combining mark is one letter, as the vocabulary lookup
2006 reads it (#665). The per-token predicates (`is_initial_shaped`,
2007 `composed_len`) fold inline instead, a call per token being the
2008 frame budget's scaling cost."""
2009 return text if text.isascii() else unicodedata.normalize("NFC", text)
2010
2011
2012def composed_len(text: str) -> int:
2013 """The length of TEXT in letters as the writer sees them: code
2014 points once NFC-composed. A caps word admitted decomposed (#652)
2015 is longer than its composed twin, and the two-letter pair test
2016 (`John Smith, MÁ`) counts letters."""
2017 return len(text if text.isascii() else unicodedata.normalize("NFC", text))
2018
2019
2020#: The marks a given-name key keeps: the tilde on n and the cedilla on
2021#: c, which spell other letters in Spanish and Catalan (Peña, Llorenç),
2022#: where an acute or grave accent or a diaeresis is how the same name
2023#: is spelled with or without them (José, Jose).
2024_KEPT_MARKS = frozenset({("n", "\u0303"), ("c", "\u0327")})
2025
2026
2027def given_name_key(text: str) -> str:
2028 """`text` folded as `Lexicon.given_names` is matched: the
2029 `_normalize` fold, with the accents and the diaeresis dropped and
2030 the tilde on n and the cedilla on c kept. tools/es_given_names.py
2031 writes the list with this same fold."""
2032 out: list[str] = []
2033 for ch in unicodedata.normalize("NFD", _normalize(text)):
2034 if unicodedata.combining(ch) and not (
2035 out and (out[-1], ch) in _KEPT_MARKS):
2036 continue
2037 out.append(ch)
2038 return unicodedata.normalize("NFC", "".join(out))
2039
2040
2041def is_listed_given_name(text: str, lexicon: Lexicon) -> bool:
2042 """Whether `text` is in `lexicon.given_names`, as written or by
2043 `given_name_key`, so a caller's entry "José" matches as written
2044 and a list written without accents matches "José" too (rules/O6's
2045 veto, #959)."""
2046 names = lexicon._sets.given_names
2047 return bool(names) and (_normalize(text) in names
2048 or given_name_key(text) in names)
2049
2050
2051def in_any_wordlist(n: str, lexicon: Lexicon) -> bool:
2052 """Whether the folded word `n` is in ANY of the lexicon's wordlists
2053 (`_lexicon._VOCAB_FIELDS`, the whole roster but four): the caps
2054 shape's "unlisted" (`caps_shape_candidate`), where a word a caller
2055 listed even as a SURNAME must never become a credential.
2056
2057 The four left out: `title_phrases`, whose entries are two words and
2058 so never one; `titles_ambiguous`, a title that may be a given
2059 name, which outside a title phrase is read as the unlisted word it
2060 is there (rules/H9: "and elsewhere a name word") -- a phrase is two
2061 words and a comma part read by this shape is one; and
2062 `given_name_abbreviations`, a given name written short (#789),
2063 whose one reader is H2's shape, so anywhere else its word reads as
2064 the unlisted word it is ('John Smith, JAS' keeps suffix 'JAS');
2065 and `given_names`, whose one reader is O6 (#959), for the same
2066 reason ('Ana Pérez, LUIS' reads as with the list empty)."""
2067 sets = lexicon._sets
2068 for field in _LISTED_FIELDS:
2069 if n in getattr(sets, field):
2070 return True
2071 return False
2072
2073
2074_LISTED_FIELDS = tuple(f for f in _VOCAB_FIELDS
2075 if f not in ("title_phrases", "titles_ambiguous",
2076 "given_name_abbreviations",
2077 "given_names"))
2078
2079
2080#: The wordlists that claim a word as something OTHER than name text.
2081#: `surnames` and `bound_given_names` are left out: they say a word IS
2082#: a name word ('Kim', 'Abdul'), the opposite claim; and so is
2083#: `titles_ambiguous`, a title that may be a given name (rules/H9), with
2084#: `title_phrases`, whose two-word entries never match one word; and so
2085#: is `given_name_abbreviations`, a given name written short (#789), left
2086#: out of `_LISTED_FIELDS` above, with `given_names` (#959).
2087_NON_NAME_FIELDS = tuple(f for f in _LISTED_FIELDS
2088 if f not in ("surnames", "bound_given_names"))
2089
2090
2091def written_as_a_name(text: str) -> bool:
2092 """Whether TEXT is written the way a name is written in mixed case:
2093 it holds a capital (or titlecase letter) and its last LETTER is
2094 lowercase -- #564's test for the name's case contrast (Derek).
2095 'Smith', 'DiCaprio', 'IJzerman', 'al-Rashid', "d'Estaing",
2096 'McDonald', 'Džokić' pass. A surname written in capitals fails
2097 whatever is glued in front of it ("d'ESTAING", 'al-ASSAD',
2098 'McDONALD', 'FitzGERALD', 'DeVITO', 'St-PIERRE'), and so do a lone
2099 capital and a lowercase-only word.
2100
2101 The letter read is the last one that carries case evidence, not the
2102 last character. The word is composed first (NFC), so a name typed
2103 with decomposed accents reads as its composed spelling ('André'
2104 ends in 'é', not in the combining accent). Passed over, as no
2105 evidence of case: every non-letter ("Jones'", 'Smith2', 'Smith)'),
2106 every caseless letter ("Jonesʼ" with U+02BC, 'Wafāʾ', 'Smith李'),
2107 and a lowercase letter whose capital is not a single character
2108 ('ß' -> 'SS', 'ĸ' with none): 'WEIß' and 'KAĸ' are written in
2109 capitals, 'Weiß' is not. One call and no generator, so a word costs
2110 one frame."""
2111 word = unicodedata.normalize("NFC", text)
2112 i = len(word)
2113 while i:
2114 i -= 1
2115 ch = word[i]
2116 if not (ch.isupper() or ch.islower() or ch.istitle()):
2117 continue # a non-letter or a caseless letter
2118 upper = ch.upper()
2119 if ch.islower() and (upper == ch or len(upper) != 1):
2120 continue
2121 # the capital is asked here, of the word that has a letter to
2122 # read; one with none falls through below ('李', '2', 'ß')
2123 return ch.islower() and text != text.lower()
2124 return False
2125
2126
2127def claimed_as_non_name(n: str, lexicon: Lexicon) -> bool:
2128 """Whether a wordlist claims the folded word `n` as a title,
2129 particle, connective, credential, generation, maiden marker or
2130 honorific -- not name text. #564's `_comma._name_contrast`
2131 asks it: such a word does not supply the name's case contrast. A
2132 different question from `in_any_wordlist`'s, which a surname list
2133 must also answer yes to."""
2134 sets = lexicon._sets
2135 for field in _NON_NAME_FIELDS:
2136 if n in getattr(sets, field):
2137 return True
2138 return False
2139
2140
2141# The candidate test for a comma part read before classify
2142# (rules/C4, decisions/S9): segment's C2 flag on a tail part.
2143def ambiguous_class_candidate(text: str, lexicon: Lexicon,
2144 policy: Policy) -> bool:
2145 """Whether TEXT is a CANDIDATE for the ambiguous credential class
2146 as segment's C2 flag asks it of a part past the second comma,
2147 before classify has tagged anything (`_segment.py`'s `class_run`;
2148 the comma's own decision, `_comma.decide`, reads classify's tags
2149 instead, #613): the LISTED half (`ambiguous_class_member`, case-free) OR, where Policy
2150 admits it, the SHAPE an unlisted dotted token wears
2151 (`period_joined_vocab`'s third verdict, #516).
2152
2153 Case-free throughout, and the CAPS half of the class is
2154 deliberately not here: its real site is `_comma.decide`, which
2155 calls `caps_shape_candidate` directly because the caps shape needs
2156 the name's contrast. A second route through here existed briefly,
2157 behind an optional `one_case` parameter no production caller ever
2158 passed -- segment had nothing to hand in at the single-token
2159 test it then made -- so the branch
2160 answered False for every name the library parsed and only the
2161 unit tests reached it (review-round finding, #289/#516). A dead
2162 second route is a place for the two to disagree, not a
2163 convenience.
2164
2165 A period anywhere is the gate for even ASKING the shape question,
2166 checked before the shape's own two calls: `period_joined_vocab`
2167 already declines a period-free text, but a Python-level call is
2168 not free and a comma name's post-comma part usually has no period
2169 ("Smith, John") -- measured regression, #516 review round, fixed
2170 by hoisting the same cheap substring test `ambiguous_class_member`
2171 already makes for its own reason. Where a period IS present,
2172 `suffix_as_written` runs only after `period_joined_vocab` says
2173 "shape": a dotted whole-token match with no single-chunk
2174 vocabulary hit of its own ('A.B.C.', via 'abc') would otherwise
2175 read "shape" from this function's chunk-level view alone,
2176 oblivious to the WHOLE-token match `suffix_as_written` already
2177 settled -- the same precedence classify's own tag order gives it
2178 (`vocab:suffix` is set before `period_joined_vocab` is even
2179 consulted).
2180
2181 A LISTED member spelled with its periods is excluded from the
2182 shape branch by the same test classify's own shape branch makes:
2183 a caller who puts a dotted entry in `suffix_acronyms_ambiguous`
2184 has said the word is a listed member of this class, and reading
2185 it by shape instead loses the case lean the listing asks for
2186 (review-round finding, #289/#516 -- `Jack A.B.` with 'a.b' listed).
2187 It cannot change the answer for the shipped vocabulary, whose
2188 ambiguous entries carry no period at all.
2189
2190 This function and classify's tag emission (`_tags_for`'s
2191 `derived == "shape"` branch) still ask the SAME question twice, of
2192 necessity: segment's C2 flag runs before `classify` and has no
2193 tags to read yet, so the two stages cannot share the call. Kept from
2194 drifting by
2195 `test_classify.test_ambiguous_class_candidate_agrees_with_the_tag`,
2196 which asks both of the same texts, rather than by a sentence
2197 alone.
2198 """
2199 if ambiguous_class_member(text, lexicon):
2200 return True
2201 if "." in text:
2202 # `_normalize` stays behind the shape verdict, where it always
2203 # was: it is a call, and a dotted post-comma token that is not
2204 # acronym-shaped at all ('Jr.') must not pay for it.
2205 if not (policy.unlisted_dotted_suffixes
2206 and period_joined_vocab(text, lexicon) == "shape"):
2207 return False
2208 n = _normalize(text)
2209 return (n not in lexicon._sets.suffix_acronyms_ambiguous
2210 and not suffix_as_written(n, text, lexicon))
2211 return False
2212
2213
2214# mechanisms.md#UNIT-PARTITION: "A rule that counts name words counts
2215# those units, and takes each whole or not at all" -- C3's count before
2216# a comma being the stated exception (`SURNAME_UNIT_TAGS`). The walk is shared
2217# by readers that hold the facts in different forms: post_rules and
2218# `_comma._whole_name` read classify's TAGS, while `name_word_count`
2219# builds the same tag names from the vocabulary
2220# (`surname_unit_tags`). One walk over tag sets, so the two cannot
2221# disagree about where a unit ends (mechanisms.md#ONE-PREDICATE-PER-
2222# QUESTION) -- only, at worst, about a token's facts, which
2223# test_classify's agreement test pins.
2224def unit_ends(tags: Sequence[Set[str]], chain: bool = True) -> list[int]:
2225 """The END (one past the last index) of each unit of `tags`, in
2226 order, partitioning `range(len(tags))`: one name word each, except
2227 where another rule has already made several words one name -- a
2228 particle and the words it chains (P2), a conjunction-joined run
2229 (P3), and a bound given-name word with the word it completes (P5).
2230 Each element is the tag set of one token, read for "particle",
2231 "conjunction", "vocab:bound-given" and "vocab:suffix".
2232
2233 `chain` picks how far a particle reaches. True is P2's chain: the
2234 particle run and every word after it up to the next stop ("van der
2235 Berg Smith" is one unit). False is P1's fold reach: the particle
2236 run and the ONE unit it attaches to, so "de Mesnil Jean" is two --
2237 the count rules/C3 needs before a comma, where under a
2238 family-first order that part is family 'de Mesnil', given 'Jean'
2239 (#575).
2240
2241 RECURSIVE, and that is the whole point: what a conjunction or a
2242 bound given-name word joins is the next UNIT, not the next word.
2243 Absorbing a single index instead strands a particle at the end of
2244 the unit, severed from the words it chains -- "de la Vega y la
2245 Vega" cut between `la` and `Vega`, reporting family
2246 "de la Vega y la", which is the same defect as a bare particle
2247 opening the given name, mirrored."""
2248 n = len(tags)
2249
2250 def end_of(i: int) -> int:
2251 if "particle" in tags[i] and not chain:
2252 j = i + 1
2253 while j < n and "particle" in tags[j]:
2254 j += 1
2255 end = (end_of(j) if j < n and "conjunction" not in tags[j]
2256 and "vocab:suffix" not in tags[j] else j)
2257 elif "particle" in tags[i]:
2258 j = i
2259 while j + 1 < n and "particle" in tags[j + 1]:
2260 j += 1
2261 # ... then the words it joins, stopping where the next
2262 # particle starts a group of its own, at a suffix word (the
2263 # stop _group's chain uses), or at a conjunction, which the
2264 # shared loop below joins to the whole unit after it rather
2265 # than to the one word after it.
2266 while (j + 1 < n
2267 and "particle" not in tags[j + 1]
2268 and "conjunction" not in tags[j + 1]
2269 and "vocab:suffix" not in tags[j + 1]):
2270 j += 1
2271 end = j + 1
2272 else:
2273 end = i + 1
2274 if "vocab:bound-given" in tags[i] and end < n:
2275 end = end_of(end)
2276 while end + 1 < n and "conjunction" in tags[end]:
2277 end = end_of(end + 1)
2278 return end
2279
2280 ends: list[int] = []
2281 i = 0
2282 while i < n:
2283 i = end_of(i)
2284 ends.append(i)
2285 return ends
2286
2287
2288#: The facts rules/C3's count before a comma reads (#575): a
2289#: particle starts a surname unit, and a suffix word stops it -- any
2290#: suffix word, a wider stop than group's particle chain makes, which
2291#: a suffix word ends only where a suffix run would start at it
2292#: (decisions/P2, #1177); #1259 left this count as it was. A word
2293#: that is ALSO title vocabulary ('Freiherr', 'St') LEADING the part is
2294#: not a particle here: in front of a name it reads as the title it
2295#: also is (rules/H1), so 'Freiherr von Berg, PhD' keeps its title,
2296#: while inside a surname it chains as P1 and P2 chain it ('de St
2297#: Pierre' is one surname). A bound given-name head that is also a
2298#: particle ('Abu') stays a particle: 'Abu Bakar, Ed' is a surname
2299#: before the comma, as 2.0 through 2.3 read it. Neither of the other
2300#: two joins `unit_ends` knows is read. A bound
2301#: given-name pair builds a GIVEN name, and P5 gives up a family word
2302#: where the name has no other ('abdul Salam' alone is given 'abdul',
2303#: family 'Salam'), so before a comma it is not one surname. Whether
2304#: P3 joins a single-letter connective depends on the words of the
2305#: whole name -- 'Carod i Rovira, Josep' joins as four words where
2306#: 'Ortega y Gasset' alone, three words, does not -- and the count is
2307#: part of deciding what the whole name is, so a count taken before
2308#: group could reach P3's answer only by copying P3. script_segment
2309#: and `name_word_count` build these facts from the vocabulary
2310#: (`surname_unit_tags`); `_comma._whole_name` derives them from
2311#: classify's tags (`surname_unit_facts`), so the counts read one set
2312#: of facts.
2313SURNAME_UNIT_TAGS = frozenset({"particle", "vocab:suffix"})
2314_PARTICLE_ONLY = frozenset({"particle"})
2315_SUFFIX_ONLY = frozenset({"vocab:suffix"})
2316_NO_TAGS: frozenset[str] = frozenset()
2317def surname_unit_facts(tags: Set[str], leading: bool) -> frozenset[str]:
2318 """`SURNAME_UNIT_TAGS` for one token, from classify's tags: the
2319 reading `_comma._whole_name`'s count before a comma takes.
2320 `leading` is whether
2321 the token opens the part."""
2322 particle = ("particle" in tags
2323 and not (leading and "vocab:title" in tags))
2324 if "vocab:suffix" in tags:
2325 return SURNAME_UNIT_TAGS if particle else _SUFFIX_ONLY
2326 return _PARTICLE_ONLY if particle else _NO_TAGS
2327
2328
2329def surname_unit_tags(text: str, lexicon: Lexicon,
2330 leading: bool) -> frozenset[str]:
2331 """`surname_unit_facts` for one token from the vocabulary alone --
2332 the vocabulary's view of classify's tags, for a count taken before
2333 classify runs (script_segment's) or without its tags
2334 (`name_word_count`), with
2335 classify's own tests: particle and title membership,
2336 `suffix_as_written`, and the period-joined derivation.
2337 Kept from drifting by
2338 test_classify.test_surname_unit_tags_agree_with_classify."""
2339 n = _normalize(text)
2340 # classify's whole-token test, then its period-joined derivation
2341 # for a token no whole-token title or suffix claimed ('JD.CPA'),
2342 # asked only of a token with a period, which it needs
2343 suffix = suffix_as_written(n, text, lexicon) or (
2344 "." in text and n not in lexicon._sets.titles
2345 and period_joined_vocab(text, lexicon) == "suffix")
2346 particle = n in lexicon._sets.particles and not (
2347 (leading and n in lexicon._sets.titles) or _spells_an_initial(n, text))
2348 if suffix:
2349 return SURNAME_UNIT_TAGS if particle else _SUFFIX_ONLY
2350 return _PARTICLE_ONLY if particle else _NO_TAGS
2351
2352
2353#: The connective after a title that may be a given name which makes
2354#: the two a title phrase, 'Dean of Students' (rules/H9). Coordinating
2355#: connectives are not: 'Dean and Mary Smith' is two people.
2356_PHRASE_LINKS = frozenset({"of"})
2357#: The connectives whose object a modifier after them is in, 'of the
2358#: United States', 'The Grand Master'; 'and' joins two titles instead.
2359_OBJECT_LINKS = frozenset({"of", "the"})
2360
2361#: The article that is the phrase partner of a style that may be a
2362#: given name, written short (rules/H9, #999): `The Hon`.
2363_STYLE_ARTICLES = frozenset({"the"})
2364
2365
2366# rules/H9: "a word no listed phrase holds is a title only behind a
2367# style or a modifier, or before `of`" -- whether the word heads a title
2368# of itself, asked of the phrases `attaches` compiled rather than of a
2369# property of its own
2370def in_a_title_phrase(text: str, lexicon: Lexicon) -> bool:
2371 """Whether a phrase of `lexicon._sets.title_phrases` that classify can
2372 read holds `text`."""
2373 return _normalize(text) in _phrase_words(
2374 lexicon._sets.title_phrases, lexicon._sets.titles, lexicon._sets.titles_ambiguous)
2375
2376
2377@functools.lru_cache(maxsize=16)
2378def _phrase_words(phrases: frozenset[str], titles: frozenset[str],
2379 contested: frozenset[str]) -> frozenset[str]:
2380 """The words of the phrases classify can read: two words, each a
2381 title or a title that may be a given name, as its pair test asks.
2382 A phrase it never consults makes no word report."""
2383 listed = titles | contested
2384 pairs = (p.split(" ") for p in phrases)
2385 return frozenset(w for ws in pairs
2386 if len(ws) == 2 and all(w in listed for w in ws)
2387 for w in ws)
2388
2389
2390# rules/H9: "the word is a title only inside a title phrase, and
2391# elsewhere a name word" -- both halves, the partners of a title that may
2392# be a given name and of a modifier at the head of the name, each its
2393# bullet's (decisions/H9 #752)
2394def title_readings(texts: Sequence[str], folded: Sequence[str],
2395 lexicon: Lexicon, *, head: bool,
2396 titled: Sequence[bool] | None = None) -> list[bool]:
2397 """Which of these words, one part of the name in order, read as
2398 title words. Where none of them is a title that may be a given name
2399 or a modifier, the listed titles answer alone, which every caller
2400 asks first, in C, so that a name with neither pays no frame.
2401
2402 A word of `titles_ambiguous` that is not also in `titles` (where a
2403 caller listing it makes it a title everywhere) is a title only
2404 beside a partner: a
2405 modifier, a style, or a title it stands behind in `title_phrases`,
2406 before it, and for a style of `title_abbreviations` the article
2407 'the' before it ('The Hon' under a Chinese choice); or 'of', or a title it stands in front of in
2408 `title_phrases`, after it. A salutation is never one, nor any other
2409 title before it ('Mr Dean Smith'), nor a title after it that it
2410 does not attach to ('SMITH, DEAN MR.', a given name and the
2411 salutation a donor list writes after it). Its neighbours are asked
2412 as LISTED, so 'Vice Dean' reads 'Vice' as the partner of 'Dean'
2413 whatever the modifier test below makes of 'Vice', save a word before
2414 it that is itself a title that may be a given name, which is a
2415 partner only where its own phrase made it a title ('Grace Dean
2416 Smith' reads 'Grace' as the given name, and 'Dean' with it).
2417
2418 Where `head` is true -- the part before the first comma -- a word
2419 of `title_modifiers` is a title only with a head title behind it in
2420 its run of titles: any title that is not a modifier, or a word H2's
2421 shape reads as one, before 'of' ('Mother Superior of the Convent'),
2422 behind a title that attaches to it in `title_phrases` ('Princess
2423 Royal', #913), or in the object of 'of' or 'the' there (decision 10 of
2424 decisions/vocabulary-model). Only the leading run is the head: a
2425 modifier outside it is read as listed, one ending the name being
2426 H5's. A
2427 word in both sets takes the modifier test in every part, that run
2428 being its phrase ('Lau, Wing Kai' reads 'Wing' as the given name).
2429 After a comma any other modifier is read as listed: a part there
2430 that is a lone title word is the person's role ('John Smith,
2431 Resident'), and reading it as a name would reverse the name
2432 (decisions/H9).
2433
2434 `texts` are the words as written, for the shape test; `folded`
2435 their `_normalize`d forms; `titled` which of them a caller already
2436 reads as titles, where that is more than `titles` lists (classify's
2437 period-joined `Lt.Gov.`), else membership in `titles`. Only a word
2438 of the two sets is ever read out of the title."""
2439 contested = lexicon._sets.titles_ambiguous
2440 modifiers = lexicon._sets.title_modifiers
2441 titles = lexicon._sets.titles
2442 # a word in titles is a title wherever it stands, whatever else it
2443 # is listed as; one of titles_ambiguous alone is listed as a title
2444 # to its neighbours and read by its phrase
2445 reading = [n in titles or n in contested for n in folded]
2446 if titled is not None:
2447 reading = [r or t for r, t in zip(reading, titled)]
2448 listed = list(reading)
2449 phrases = lexicon._sets.title_phrases
2450 last = len(folded) - 1
2451 for i, n in enumerate(folded):
2452 if n in titles or n not in contested or n in modifiers:
2453 continue
2454 prev = folded[i - 1] if i else None
2455 nxt = folded[i + 1] if i < last else None
2456 reading[i] = bool(
2457 # the word before as it was READ, left to right: a title that
2458 # may be a given name and stands in no phrase is no partner
2459 # ('Grace Dean Smith')
2460 (prev is not None and reading[i - 1]
2461 and (prev in modifiers or prev in lexicon._sets.title_styles
2462 or f"{prev} {n}" in phrases))
2463 # rules/H9: "a style that may be a given name, written short,
2464 # has its phrase partner in the article before it" -- 'The
2465 # Hon' under a Chinese choice (decisions/H9, #999)
2466 or (prev in _STYLE_ARTICLES and n in lexicon._sets.title_styles
2467 and n in lexicon._sets.title_abbreviations
2468 and prev in lexicon._sets.conjunctions)
2469 or (nxt is not None
2470 and ((nxt in _PHRASE_LINKS and nxt in lexicon._sets.conjunctions)
2471 or (listed[i + 1] and f"{n} {nxt}" in phrases))))
2472 # left to right, whether the run so far has passed 'of' or 'the': a
2473 # modifier after it is in the object of a phrase ('President of the
2474 # United States', 'the Queen's Bench'), which no head need follow. A
2475 # coordinating connective opens no object: 'Mr. and Mrs. Wing Lau'
2476 # is two salutations and a given name
2477 linked = [False] * len(folded)
2478 conjunctions = lexicon._sets.conjunctions
2479 for i in range(1, len(folded)):
2480 if reading[i - 1] or folded[i - 1] in conjunctions:
2481 linked[i] = linked[i - 1] or folded[i - 1] in _OBJECT_LINKS
2482 # the head of the name is its LEADING run of titles and connectives:
2483 # a modifier ending the name is H5's trailing run, read as listed,
2484 # save one that may be a given name, which the loop below reads
2485 # wherever it stands
2486 lead = 0
2487 if head:
2488 while lead < len(folded) and (reading[lead] or folded[lead] in conjunctions
2489 or is_title_shaped(texts[lead], lexicon)):
2490 lead += 1
2491 # right to left, so each modifier knows whether a head title stands
2492 # behind it before the run is broken; a connective carries the run
2493 behind = False
2494 for i in range(last, -1, -1):
2495 n = folded[i]
2496 if (n in modifiers and reading[i]
2497 and ((head and i < lead) or (n in contested and n not in titles))):
2498 reading[i] = behind or linked[i] or (
2499 i < last and folded[i + 1] in _PHRASE_LINKS
2500 and folded[i + 1] in conjunctions) or (
2501 # a title before it that attaches to it ('Princess Royal')
2502 i > 0 and reading[i - 1]
2503 and f"{folded[i - 1]} {n}" in phrases)
2504 behind = reading[i]
2505 elif reading[i] or (head and is_title_shaped(texts[i], lexicon)):
2506 behind = True
2507 elif n not in conjunctions:
2508 behind = False
2509 return reading
2510
2511
2512def name_word_count(texts: Sequence[str], lexicon: Lexicon,
2513 policy: Policy, letters_one_case: bool | None = None,
2514 members: Set[int] = frozenset()) -> int:
2515 """How many of these texts are NAME words -- not suffix
2516 vocabulary, not title vocabulary, nor a numeral the trailing fork
2517 reads as the generation ending the name words (rules/C3).
2518
2519 rules/C4's count for the ambiguous class, and it is of names
2520 rather than of words because 'Smith Jr., MA' is two tokens and one
2521 name: counting tokens there flips the structure and hands the
2522 family to `given`, which no reading of that string wants
2523 (decisions/S6). The suffix half asks the POLICY-selected
2524 predicate, the same one is_wholly_suffix asks, so the two agree
2525 about what a suffix word is; the title half asks BOTH the listed
2526 lookup and H2's shape test (`is_title_shaped`) -- a bare
2527 `lexicon._sets.titles` lookup here read 'Xyz.' as a name word where the
2528 leading peel reads it as a title, and that divergence is recorded
2529 once, at `is_title_shaped` itself
2530 (mechanisms.md#ONE-PREDICATE-PER-QUESTION).
2531
2532 It counts UNITS holding a name word, not tokens (#575,
2533 mechanisms.md#UNIT-PARTITION): a particle chain (P2) builds one
2534 family name, so 'De La Cruz' is one name word and 'De La Cruz, Ed'
2535 reads as 'Royce, Ed' does. Why the other two joins are not read
2536 here: `SURNAME_UNIT_TAGS`.
2537
2538 `letters_one_case` is `is_wholly_suffix`'s, for the same reason,
2539 and the case fact the generation fork reads (`ParseState.one_case`).
2540 `members` holds the positions of split credentials' members
2541 (classify's tag), each half a credential rather than a name, so
2542 one opening the part stays no name word, as `_comma._whole_name`
2543 reads it.
2544 """
2545 lenient = policy.lenient_comma_suffixes
2546
2547 # One pass: each token's name-word verdict, and whether any token
2548 # is a particle. With none, every token is a unit of its own and
2549 # the count is a sum, so the commonest comma names never build the
2550 # units -- and pay no frame the token loop did not already pay.
2551 names: list[bool] = []
2552 suffixes: list[bool] = []
2553 has_particle = False
2554 lead: int | None = None
2555 lead_suffix = False
2556 # map, not a comprehension, which is a frame of its own on 3.11
2557 folded = list(map(_normalize, texts))
2558 # rules/H10: "the run reads as that title where a title the run
2559 # keeps stands behind it" -- the run read as the title it spells, so
2560 # the title test and H9's phrase test below see it. Unlike classify's
2561 # fold, which runs only where the name already holds a word H9
2562 # reads, this one always runs, so a run alone brings the phrase test
2563 # in. The set test first, C-level, so a dotted word that spells no
2564 # marked title pays no frame
2565 dotted: set[int] = set()
2566 for i, n in enumerate(folded):
2567 if ("." in n and n.replace(".", "") in lexicon._sets.dotted_titles
2568 and is_dotted_title(texts[i], n, lexicon)):
2569 folded[i] = n.replace(".", "")
2570 dotted.add(i)
2571 # rules/H9: "the word is a title only inside a title phrase" -- the
2572 # part before the comma is the head of the name, read as classify
2573 # reads it
2574 titled = (None if lexicon._sets.titles_ambiguous.isdisjoint(folded)
2575 and lexicon._sets.title_modifiers.isdisjoint(folded)
2576 else title_readings(texts, folded, lexicon, head=True))
2577 for i, n in enumerate(folded):
2578 text = texts[i]
2579 title = n in lexicon._sets.titles if titled is None else titled[i]
2580 if title and i in dotted:
2581 # a title only opening the part (behind any titles), and only
2582 # with a title, listed or by H2's shape, behind it and a word
2583 # of any kind behind that: H6 leaves the part's last word to
2584 # the family, so a title ending the part is not kept and the
2585 # run is given back ('U.S. Senator, Ed'); H3's floor is not
2586 # modelled here (decisions/H10)
2587 nxt = i + 1
2588 title = (lead is None and nxt < len(folded)
2589 and ((folded[nxt] in lexicon._sets.titles
2590 if titled is None else titled[nxt])
2591 or is_title_shaped(texts[nxt], lexicon))
2592 and nxt + 1 < len(folded))
2593 if n in lexicon._sets.particles:
2594 has_particle = True
2595 suffix = (is_suffix_lenient(text, lexicon) if lenient
2596 else is_suffix_strict(text, lexicon, letters_one_case))
2597 name = not (suffix or title or is_title_shaped(text, lexicon))
2598 # the opening word behind any titles; the shape test asked again
2599 # only of a suffix word there, so a run of them pays no frame
2600 if lead is None and (name or suffix and not title
2601 and not is_title_shaped(text, lexicon)):
2602 lead = i
2603 lead_suffix = not name and i not in members
2604 names.append(name)
2605 suffixes.append(suffix)
2606 # rules/C3: "A suffix word opening the part is a name word to the
2607 # count where a name word follows it" -- opening it behind its
2608 # titles, which stay no name word, this count being of names
2609 # (rules/C4)
2610 if lead_suffix and lead is not None and any(names[lead + 1:]):
2611 names[lead] = True
2612 # rules/C3: "a numeral ending the part, or standing before the
2613 # listed suffix words it ends with, that the trailing fork reads as the
2614 # generation is no name word", as a listed one is not
2615 # -- the suffix words stepped over as `_comma._whole_name` steps
2616 # over them, a dual of the title and suffix vocabulary included,
2617 # and a title that is no suffix word stopping both
2618 e = len(texts) - 1
2619 while e > 0 and suffixes[e]:
2620 e -= 1
2621 # the numeral's shape asked in C first, as group's count asks it, so
2622 # a part ending in a plain word pays no frame
2623 if (e > 0 and names[e]
2624 and _ROMAN.match(texts[e].rstrip(".")) is not None
2625 and ends_in_a_generation(texts[e - 1:e + 1], letters_one_case)):
2626 names[e] = False
2627 if not has_particle:
2628 return sum(names)
2629 count = 0
2630 start = 0
2631 for end in unit_ends([surname_unit_tags(t, lexicon, i == 0)
2632 for i, t in enumerate(texts)],
2633 chain=False):
2634 if any(names[start:end]):
2635 count += 1
2636 start = end
2637 return count
2638
2639
2640def is_wholly_suffix(texts: Sequence[str], lexicon: Lexicon,
2641 policy: Policy, one_case: bool | None = None,
2642 letters_one_case: bool | None = None,
2643 strict: bool = False) -> bool:
2644 """Every token in a RUN counts as a suffix -- C1's suffix test of
2645 a run, shared by `_comma.decide`, segment's C2 flag on a tail part
2646 and the peel (#319).
2647
2648 NOT the plural of _script_segment._is_post_nominal, which asks
2649 is_suffix_strict per token. This asks the POLICY-selected predicate
2650 (lenient by default), plus period_joined_vocab, delimiter
2651 transparency and the Ph./D. merge. 'V.' is the input that tells
2652 them apart: it satisfies this predicate but is not a post-nominal
2653 -- and reading one for the other IS the #319 bug.
2654
2655 An EMPTY run is False, not vacuously True: v1's suffix-comma
2656 detection fails on an empty parts[1] ('John Smith,, MD' is a
2657 family-comma parse). The 'wholly' idiom agrees -- _script_matcher's
2658 whole=True requires non-empty too -- which is why the name is that
2659 one rather than all_suffixes, where Python's all([]) would promise
2660 the opposite.
2661
2662 An adjacent Ph./D. pair counts as ONE unit (v1's fix_phd extracted
2663 the credential pre-parse, so 'Smith, Ph. D.' read as suffix-comma):
2664 `split_pair`'s "suffix" answer, which group's merge and
2665 `_comma._pieces` read too. Its "split" answer ('Pharm D') does not
2666 count here: that pair is a member of the ambiguous class, which no
2667 run test takes by itself, as it takes no bare 'Ma' (rules/C4's
2668 count reads it instead).
2669
2670 `one_case` is ParseState.one_case, and it admits the LEAN: a bare
2671 ambiguous acronym written in capitals inside a mixed-case name is
2672 a credential here, so 'Steven Hardman, MD, DO, DDS' reads its
2673 third segment as the credential run it is (#289). None -- the
2674 default, and what every caller with no state to ask has -- reads
2675 as no lean and is this predicate's behavior in every release
2676 before 2.4.
2677
2678 `letters_one_case` is the same fact asked a different question,
2679 and the two are kept apart because a caller that wants no lean
2680 still wants the letters read by T4: under
2681 `lenient_comma_suffixes=False` the strict test vetoes an
2682 initial, and in a name written in one case a bare lowercase
2683 letter is one ('john smith, v' reads as 'JOHN SMITH, V', #727).
2684 The lenient test takes a suffix word whatever its shape, so the
2685 default never reads it.
2686
2687 Neither Policy.unlisted_dotted_suffixes NOR
2688 Policy.unlisted_caps_suffixes reaches this predicate: admitting a
2689 by-shape member here unconditionally (an earlier version of this
2690 docstring described exactly that, for the dotted half alone)
2691 bypassed both the lean AND the NAME-word count, and combined with
2692 C1's then TOKEN-count disjunct in segment (deleted in #613, when
2693 the decision moved to `_comma.decide`) it flipped
2694 'Smith Jr., A.B.' to given 'Smith', suffix 'Jr., A.B.' with a
2695 self-contradicting report ("holds 1 name words, so it is read as
2696 a credential run") -- proved by mutation testing to be otherwise
2697 unreached: nothing but this predicate's own two unit tests
2698 depended on it, and 'John Smith, X.Y.Z.' flipped correctly through
2699 the NAME-word count alone (#516 review round). Neither by-shape
2700 class, dotted or caps, reaches the comma form through this
2701 predicate at all -- only through classify's shape tags and
2702 `_comma.decide`'s caps test (#613).
2703
2704 A delimiter core glued to one word ('MD;', ';PhD') leaves the word
2705 read by the test it would get without the core (D15, rules/C10),
2706 so 'MD;' counts where 'MD' does. `strict` asks the strict token
2707 test whatever the policy says: `_comma.decide` passes it behind a
2708 head that is not a whole name, where C8 reads 'Smith, V MD' with
2709 the initial veto, so that 'Smith, V; MD' reads as its space twin.
2710 """
2711 if not texts:
2712 return False
2713 lenient = policy.lenient_comma_suffixes and not strict
2714 # v1 expand_suffix_delimiter parity (#206): a configured delimiter
2715 # is TRANSPARENT in the all-suffix tests -- v1 split the part string
2716 # on the delimiter before checking, so the delimiter never counted;
2717 # the built-in cores are transparent the same way (rules/C10)
2718 cores = delimiter_cores(policy.extra_suffix_delimiters)
2719 inner = glued_cores(policy.extra_suffix_delimiters)
2720
2721 def counts_as_suffix(text: str) -> bool:
2722 if (one_case is not None
2723 and ambiguous_class_member(text, lexicon)
2724 and ambiguous_lean(text, one_case, lexicon_cue(
2725 _normalize(text), lexicon)) == "credential"):
2726 return True
2727 if ((is_suffix_lenient(text, lexicon) if lenient
2728 else is_suffix_strict(text, lexicon, letters_one_case))
2729 or period_joined_vocab(text, lexicon) == "suffix"):
2730 return True
2731 # the split only where a core stands in the word: the built-in
2732 # ';' makes `inner` non-empty for every policy, and a call per
2733 # word would be a frame on every name word a run test asks
2734 for core in inner:
2735 if core in text:
2736 stem = glued_stem(text, inner)
2737 if stem is not None:
2738 return counts_as_suffix(stem)
2739 return splits_into_suffixes(text, inner, lexicon)
2740 return False
2741
2742 merged = list(texts)
2743 k = 0
2744 while k < len(merged) - 1:
2745 if (SPLIT_PAIR_OPENS.fullmatch(merged[k])
2746 and split_pair(merged[k], merged[k + 1], lexicon)
2747 == "suffix"):
2748 merged[k:k + 2] = ["phd"]
2749 else:
2750 k += 1
2751 return all(t in cores or counts_as_suffix(t) for t in merged)
2752
2753
2754# The two derived views of a marker vocabulary, cached per-vocabulary
2755# on _script_segment._longest_entry's precedent -- same function shape
2756# (a scalar derived from a vocabulary frozenset), same key space, and
2757# its reasoning for maxsize=16 carries over verbatim: a process holds
2758# the default vocabulary plus one per constructed pack parser, so 16
2759# bounds many-lexicon churn without ever evicting in normal use.
2760# (NOT _extract._delimiter_chars' precedent, which these once cited:
2761# that one is consulted once per parse, so it says nothing about a
2762# lookup on the per-token path.) Keying on the frozenset costs a
2763# cached hash, not a sweep of its contents.
2764@functools.lru_cache(maxsize=16)
2765def _longest_marker(markers: frozenset[str]) -> int:
2766 """How many words the longest entry of `markers` spans -- the
2767 lookahead bound, computed from the vocabulary rather than fixed at a
2768 literal so a caller's four-word entry works and an all-single-word
2769 set leaves the common path at one lookup. Entries are stored
2770 space-joined with single separators, so the space count IS the word
2771 count."""
2772 return max((entry.count(" ") + 1 for entry in markers), default=0)
2773
2774
2775@functools.lru_cache(maxsize=16)
2776def _marker_heads(markers: frozenset[str]) -> frozenset[str]:
2777 """The first word of every entry -- the set a run can possibly open
2778 with. Entries are already stored per-word folded, so an entry's
2779 first word is the same fold the lookup builds."""
2780 return frozenset(entry.split(" ", 1)[0] for entry in markers)
2781
2782
2783def maiden_marker_head(n: str, markers: frozenset[str]) -> bool:
2784 """Could a maiden marker run START here? `n` is _normalize(word),
2785 passed in so callers fold once -- suffix_as_written's shape exactly
2786 ("`n` is `_normalize(text)`, passed in so callers normalize once"),
2787 and with no raw-text sibling for the same reason it has none: every
2788 caller is on the per-token path and has the fold in hand already.
2789
2790 A SUPERSET test. True means only that some entry opens with this
2791 word, never that a run matches -- maiden_marker_run is the answer,
2792 and it calls this function, so the two cannot drift. Exported
2793 because a caller scanning a whole token stream needs to know
2794 whether assembling a candidate sequence is worth doing at all, and
2795 for almost every token it is not: _classify's pass would otherwise
2796 walk structural boundaries once per token to build a lookahead the
2797 predicate discards on this very test.
2798 """
2799 return n in _marker_heads(markers)
2800
2801
2802def maiden_marker_run(words: Sequence[str], markers: frozenset[str]) -> int:
2803 """How many of `words` a maiden marker claims, longest first; 0 for
2804 none.
2805
2806 Phrases are stored space-joined and per-word normalized (_title_key's
2807 storage rule), so the key is rebuilt the same way here -- normalizing
2808 the joined phrase instead would leave interior periods.
2809
2810 `words` must be words that stand TOGETHER -- one clause's, or one
2811 segment's. This answers only what the vocabulary says about the
2812 sequence it is handed; whether a sequence is a sequence is the
2813 caller's, and classify's contiguity rule is where that is decided
2814 for the token stream.
2815
2816 Longest first, so a caller configuring both 'geb' and 'geb von' gets
2817 the phrase where it matches and the bare word everywhere else. The
2818 one answer to "does a marker start here, and where does it end": the
2819 stages that can call it do (classify over token texts, extract over
2820 a clause's whitespace words), and the stage that runs after classify
2821 reads the tags classify recorded instead
2822 (mechanisms.md#ONE-PREDICATE-PER-QUESTION).
2823 """
2824 # Fast path first: no entry opens with this word, so no length can
2825 # match. It cannot hide a match -- every key the loop builds opens
2826 # with _normalize(words[0]) unless that word folds away, and a
2827 # folded-away first word fails the n-word test below for every
2828 # n > 1 and is not a stored entry for n == 1.
2829 if not words:
2830 return 0
2831 head = _normalize(words[0])
2832 if not maiden_marker_head(head, markers):
2833 return 0
2834 cap = min(len(words), _longest_marker(markers))
2835 # Fold each word ONCE, then key from a prefix. _title_key(words[:n])
2836 # per candidate length re-folds the whole prefix every time, which
2837 # is quadratic in cap and makes a one-word hit cost more than a
2838 # two-word one -- the longest key is always built and discarded
2839 # first, and all but one shipped entry is a single word. The join
2840 # below IS _title_key's body over pre-folded words (per-word fold,
2841 # empties dropped, space-joined); test_vocab pins that the two
2842 # agree, since this is a copy of a fold whose definition lives in
2843 # _lexicon.
2844 folded = [head] + [_normalize(w) for w in words[1:cap]]
2845 for n in range(cap, 0, -1):
2846 key = " ".join(filter(None, folded[:n]))
2847 # a word that folds away is DROPPED from the key, so 'née'
2848 # followed by a lone '.' would key as 'née' and a one-word
2849 # marker would claim the period as part of its run. n words in,
2850 # n words out: anything else is not this key's n-word phrase.
2851 if key.count(" ") == n - 1 and key in markers:
2852 return n
2853 return 0
2854
2855
2856
2857# rules/M3: "a marker followed by a comma is no marker"
2858def comma_follows_marker(word: str) -> bool:
2859 """Whether `word`, the one standing after a marker run inside a
2860 delimited clause, opens with a comma -- standing alone (' , ') or
2861 glued to the name after it (' ,Jones'). A comma glued to the marker
2862 itself ('née,') needs no test: that word is no marker to begin with,
2863 the vocabulary storing no comma. The one predicate for extract's M3
2864 test over whitespace words and group's M1 drop over tokens, which
2865 since #653 hold the same comma
2866 (mechanisms.md#ONE-PREDICATE-PER-QUESTION).
2867
2868 The comma need not be the word's first character, only the first
2869 that is a comma or a letter or digit. A whitespace word can open
2870 with what the tokenizer drops or divides at -- a bidi mark, an
2871 emoji, a nakaguro -- so '\u200f, Jones' is a comma-led WORD only
2872 past its mark, while the token group asks about is ',' already;
2873 reading past the rest makes the two sites agree by construction
2874 (#653's second review)."""
2875 for ch in word:
2876 if ch in COMMA_CHARS:
2877 return True
2878 if ch.isalnum():
2879 return False
2880 return False
2881
2882
2883def tag_marker_runs(tokens: Sequence[WorkToken], comma_offsets: Sequence[int],
2884 markers: frozenset[str],
2885 folded: Sequence[str] | None = None) -> dict[int, str]:
2886 """Which tokens are maiden marker runs: index -> "vocab:maiden-marker"
2887 for a run's head, "vocab:maiden-marker-cont" for the rest.
2888
2889 Returns the decision rather than rewriting the tokens: a caller
2890 that writes tags into the one pass that builds them (classify)
2891 consults this map rather than deciding a run twice, so a marker
2892 token is never replaced twice; a caller that never writes tags at
2893 all (own_words) reads the same map for its span instead.
2894
2895 Moved here from classify (#289/#516) so a caller outside classify
2896 -- _pieces.own_words, when it has no marker map yet -- can build
2897 the SAME map classify would, rather than approximating it with a
2898 text-only head test. `_pieces.py` may import only _state and
2899 _vocab (tests/v2/test_layering.py), so the one shared function
2900 both sites call has to live here, in the layer both can reach.
2901
2902 `folded` is _normalize per token; classify has already built it
2903 for the vocabulary-tag pass and hands it over so this pass costs
2904 no second fold, and a caller with only tokens (own_words' self-
2905 built path) omits it and pays the fold here instead -- paid only
2906 on that path, never on classify's, so the reference name's frame
2907 count is unchanged (#289/#516).
2908
2909 The one sequence pass in this stage, and it has to be one: a marker
2910 entry may be a PHRASE whose words are not markers individually
2911 ('z', 'domu'), so no per-token membership test can find it.
2912 Left to right, longest first at each position, then skip past what
2913 the run claimed -- a second marker cannot start inside the first.
2914
2915 This is where the tag is DECIDED for the stages that read it
2916 afterwards. group runs later and asks its questions of these tags
2917 rather than re-deriving the run (the recorded-answer half of
2918 mechanisms.md#ONE-PREDICATE-PER-QUESTION); extract runs EARLIER,
2919 before tokens exist, so it calls the predicate itself over the
2920 clause's whitespace words.
2921
2922 A tagged run is structurally contiguous, and the test is
2923 one-directional: a role change IS a clause edge, so no run spans
2924 one, but not every clause edge is a role change -- two ADJACENT
2925 clauses of the same role are indistinguishable here, and
2926 'Jane (z) (domu) Jones' does tag a run across them. Both consumers
2927 refuse that run for reasons of their own (the piece walk never sees
2928 role-bearing tokens at all; the clause drop is scoped to one
2929 clause's span), so no reading depends on it today, and the claim
2930 this pass can honestly make is the weaker one. What it does
2931 guarantee is what _group._marker_run_pieces needs: a run inside the
2932 MAIN stream stays inside one segment. Without it this pass walked
2933 the whole span-sorted stream while group walked one segment --
2934 _segment keeps only role-less tokens and buckets them by the commas
2935 before them -- so a run half inside a bracketed clause was tagged
2936 whole and consumed as a proper PREFIX of itself, and
2937 'Anna z (domu) Nowak' read family 'Anna', maiden 'Nowak': the bare
2938 preposition eating the name, which is the exact damage the phrase
2939 entry exists to prevent. Refusing to tag such a run is the fix;
2940 truncating it instead would hand M2 the same wrong prefix one word
2941 shorter.
2942 """
2943 # the lookahead the vocabulary actually needs; 0 for an empty set,
2944 # which skips the pass entirely
2945 cap = _longest_marker(markers)
2946 if not cap:
2947 return {}
2948 if folded is None:
2949 folded = [_normalize(t.text) for t in tokens]
2950 n_tokens = len(tokens)
2951 # Deferred, not computed up front: only the contiguity walk reads
2952 # it, only a phrase vocabulary runs that walk, and only at a token
2953 # that opens an entry -- so a single-word vocabulary, and a
2954 # phrase vocabulary over a name holding no marker, never pay the
2955 # sweep at all.
2956 buckets: list[int] | None = None
2957 tags: dict[int, str] = {}
2958 i = 0
2959 while i < n_tokens:
2960 # The predicate's own head test first, over the fold the caller
2961 # already has: almost no token opens any entry, and for those
2962 # there is nothing to assemble. Same function maiden_marker_run
2963 # consults, so a token skipped here is one it would refuse.
2964 if not maiden_marker_head(folded[i], markers):
2965 i += 1
2966 continue
2967 # Bound the lookahead at the first structural boundary, so the
2968 # predicate is asked over the words that could form one run and
2969 # answers longest-first WITHIN them -- a two-word entry refused
2970 # at a clause edge still leaves a one-word entry starting there
2971 # free to match.
2972 limit = 1
2973 if cap > 1:
2974 if buckets is None:
2975 buckets = [comma_bucket(t.span.start, comma_offsets)
2976 for t in tokens]
2977 role, bucket = tokens[i].role, buckets[i]
2978 while (limit < cap and i + limit < n_tokens
2979 and tokens[i + limit].role is role
2980 and buckets[i + limit] == bucket):
2981 limit += 1
2982 run = maiden_marker_run(
2983 [tokens[k].text for k in range(i, i + limit)], markers)
2984 if not run:
2985 i += 1
2986 continue
2987 tags[i] = "vocab:maiden-marker"
2988 for k in range(i + 1, i + run):
2989 tags[k] = "vocab:maiden-marker-cont"
2990 i += run
2991 return tags
2992
2993
2994def _normalized_for_script(text: str) -> str | None:
2995 """The guard AND the two normalizations single_script and
2996 effective_script's license path both need, single-sourced so they
2997 cannot drift: trailing full stops are dropped (FULL_STOPS, #323),
2998 then None for the two shapes neither ever classifies (nothing
2999 left, and the common all-ASCII Latin token -- skipped before
3000 normalizing, since ASCII is already NFC and every _SCRIPT_RANGES
3001 entry is non-ASCII regardless), else an NFC-normalized copy.
3002
3003 Trailing stops, not raw: a period glued to a script-written token
3004 ('양.', '太郎.') is not a character of any script, so classifying
3005 raw text handed the token no script at all, and three readers
3006 spent that None -- the surname site stepped past the family name
3007 onto the given name ('양. 지훈' cut 지훈 in half), the order rule
3008 fell back to positional ('양 지훈.' lost family-first), and the
3009 segmenter's neighbour precondition missed a writer-drawn boundary
3010 ('山田太郎 田中.' consulted the segmenter on 山田太郎 as if it stood
3011 alone). The scripts this classifies -- every _SCRIPT_RANGES entry,
3012 which today coincide with _policy._NO_INITIALS (#320), a
3013 coincidence _policy says a new member must not inherit -- have no
3014 initials and no period abbreviations, so a stop on such a token
3015 carries no information about the word; ASCII text is stripped too,
3016 but the guard below returns None for it regardless, so 'Smith.'
3017 never classifies. TRAILING only, matching the surname site's own
3018 rstrip in _script_segment (the same arithmetic, not a shared
3019 gate -- this fold decides only whether the surname site, the order
3020 rule and the segmenter ever see the token): a leading stop is not
3021 a shape any script writes before a name word, and HIDING such a
3022 token from those three readers -- no script, so no surname site,
3023 which is what the tree before #323 did -- is safer than admitting
3024 it. Admitted, '.김민준' classifies as hangul, becomes a surname
3025 site, is declined by the head match (which rstrips) and falls
3026 through to a configured segmenter, which answering offset 1
3027 divides it into the stop and the name. The peel is not gated by
3028 this fold; its own rstrip carries a leading-stop token, see
3029 _script_segment. The vocabulary fold alone reads BOTH edges
3030 (_lexicon._normalize): '.씨' is still the honorific, and a lookup
3031 divides nothing.
3032
3033 NFC, not raw: NFD input decomposes precomposed katakana onto a
3034 base character plus a COMBINING mark (U+3099/U+309A, which sit in
3035 the HIRAGANA block, not katakana's), so classifying raw NFD text
3036 can hand a pure-katakana token the kana license by accident.
3037 Decomposed hangul is conjoining jamo, which HANGUL holds, so the
3038 fold no longer decides a hangul token (decisions/nfd-shapes,
3039 #1087).
3040
3041 Voicing marks, last: NFC composes a mark only where a precomposed
3042 kana exists (カ + U+3099 is ガ), so ア + U+3099, ン + U+3099 and
3043 every spacing mark (U+309B/U+309C) survive it, and all four marks
3044 sit in the HIRAGANA block. A mark voices the kana it follows and
3045 belongs to neither syllabary, so each one standing after a kana is
3046 deleted from the copy (_drop_kana_voicing_marks) and the token
3047 takes its base's script: ア゙イ is katakana, not kana-licensed
3048 Japanese (#596). A mark after anything else stays, and stays in
3049 the hiragana block, as it always has. Classification-only and
3050 read-only, like the rest: the
3051 returned copy is never what gets tokenized, so token text and
3052 spans stay exactly what the caller wrote.
3053
3054 Vocabulary MATCHING composes NFC too, since #322
3055 (_lexicon._normalize folds every lookup and every stored entry the
3056 same way), so an NFD suffix word reaches its NFC entry. What stays
3057 raw is SEGMENTATION -- the surname site's direct membership test
3058 and the peel's tail slice index the token's own text -- where NFD
3059 degrades to no-split, never to a wrong split (decisions/W1,
3060 the 2026-07-29 ja amendment).
3061 """
3062 text = text.rstrip(FULL_STOPS)
3063 if not text or text.isascii():
3064 return None
3065 composed = unicodedata.normalize("NFC", text)
3066 if _VOICING_MARKS.isdisjoint(composed):
3067 return composed
3068 return _drop_kana_voicing_marks(composed)
3069
3070
3071def _classify(normalized: str) -> Script | None:
3072 """The FIRST _SCRIPT_MATCHERS entry covering all of `normalized`
3073 (already NFC, via _normalized_for_script), else None. Shared by
3074 both public classifiers so each of them normalizes exactly once."""
3075 for script, matcher in _SCRIPT_MATCHERS.items():
3076 if matcher(normalized):
3077 return script
3078 return None
3079
3080
3081def single_script(text: str) -> Script | None:
3082 """The one Script whose ranges cover EVERY char of `text`, else
3083 None (mixed-script text has no well-defined convention to apply;
3084 the caller falls back to the positional default). Classifies an
3085 NFC-normalized copy of `text` -- see _normalized_for_script.
3086 Callers wanting the kana-mixed license (a kanji+kana composite
3087 resolving to HIRAGANA) want effective_script, not this function."""
3088 normalized = _normalized_for_script(text)
3089 if normalized is None:
3090 return None
3091 return _classify(normalized)
3092
3093
3094def effective_script(text: str) -> Script | None:
3095 """single_script, extended by the kana license (#272 amendment):
3096 a MIXED token wholly within Han∪hiragana∪katakana is Japanese --
3097 it necessarily contains kana (pure Han is not mixed), cannot be
3098 Chinese, and is not a foreign transcription (those are
3099 katakana-only: マイケル has no kanji, but さくらエミ -- hiragana
3100 plus katakana -- is kana-only AND licensed) -- and resolves to the
3101 HIRAGANA carrier entry. Pure-katakana stays KATAKANA
3102 (single_script's answer): a lone katakana token may be a
3103 transcribed foreign name or a Japanese reading, and the script
3104 cannot say which, so nothing defaults on it. Halfwidth kana is
3105 katakana by the table (#594), so 山田タロウ is licensed too."""
3106 # None for both shapes _wholly_ja could never match anyway (empty
3107 # text, or all-ASCII text): real work, not a leftover "if text"
3108 # guard, since the ASCII case is one a bare emptiness check would
3109 # let through. The single normalized copy then serves both the
3110 # single-script answer and the license below.
3111 normalized = _normalized_for_script(text)
3112 if normalized is None:
3113 return None
3114 script = _classify(normalized)
3115 if script is not None:
3116 return script
3117 if _wholly_ja(normalized):
3118 return Script.HIRAGANA
3119 return None
3120
3121
3122def resolve_script_set(scripts: Iterable[Script]) -> Script | None:
3123 """Generalizes effective_script's kana license from one token's
3124 CHARACTERS to a whole name's PIECES (#272): `scripts` is the
3125 effective_script of every name token, already resolved
3126 individually -- '高橋' (Han) and 'みなみ' (Hiragana) are two
3127 separately single-script pieces (split by a space, not mixed
3128 within one token), but together are exactly the repertoire
3129 effective_script licenses inside a single token (高橋みなみ). A
3130 single distinct script is returned as-is (the ordinary case,
3131 including a lone wholly-katakana name, which callers key with no
3132 table entry); more than one collapses to the HIRAGANA carrier
3133 when confined to Han/Hiragana/Katakana, the same set
3134 effective_script's license tests; any other mix (Han+Hangul, or
3135 no scripts at all -- an empty `scripts`) returns None -- the
3136 caller's cue to fall back to the positional default, exactly like
3137 effective_script's own None. A non-None result reports what was
3138 FOUND, not that a license fired: callers wanting to know whether
3139 the kana license specifically was the reason must compare the
3140 result against a specific Script (e.g. `is Script.HIRAGANA`), not
3141 just its truthiness -- a lone wholly-Han name also returns
3142 non-None here, licensing nothing."""
3143 found = frozenset(scripts)
3144 if len(found) <= 1:
3145 return next(iter(found), None)
3146 if found.issubset(_JA_SCRIPTS):
3147 return Script.HIRAGANA
3148 return None
3149
3150
3151# -- the reading a decision did not take (Ambiguity.alternative, #752
3152# phase 2m). Each report names it from what its own site knows: the
3153# word's tags say which reading the vocabulary or a shape gave it, and
3154# the emitter says which field (decisions/vocabulary-model, 2026-10-26)
3155
3156#: Tags that say the vocabulary declares the word a name as well as a
3157#: word of another category: the derived markers (decisions/
3158#: vocabulary-model, 2026-10-25), and a given name written short. The
3159#: ambiguous class's tag is also written by SHAPE (rules/S9), which
3160#: `name_reading` reads off the shape tag beside it.
3161_DECLARED_NAME_TAGS = frozenset({
3162 AMBIGUOUS_ACRONYM_TAG, AMBIGUOUS_WORD_TAG, "vocab:particle-ambiguous",
3163 TITLE_AMBIGUOUS_TAG, GIVEN_ABBREVIATION_TAG})
3164
3165
3166def suffix_reading(token: WorkToken) -> PendingReading:
3167 """The suffix reading of a word a decision read as a name: a
3168 generation or agnomen by its listed word, a numeral by its shape
3169 unless listed, another listed suffix word by its vocabulary kind,
3170 and otherwise a credential, listed or shaped."""
3171 kind, source = suffix_kind(token.text, token.tags)
3172 return PendingReading(kind, source, Role.SUFFIX)
3173
3174
3175def suffix_kind(text: str,
3176 tags: Set[str]) -> tuple[ReadingKind, ReadingSource]:
3177 """`suffix_reading`'s kind and source, which assemble also asks of
3178 a word the parse read as a suffix (`Token.reading`), so the
3179 reading taken and the reading not taken are named by one
3180 predicate."""
3181 if AGNOMEN_TAG in tags:
3182 return ReadingKind.AGNOMEN, ReadingSource.VOCABULARY
3183 if CREDENTIAL_WORD_TAG in tags:
3184 return ReadingKind.CREDENTIAL, ReadingSource.VOCABULARY
3185 if AMBIGUOUS_WORD_TAG in tags or GENERATION_WORD_TAG in tags:
3186 return ReadingKind.GENERATION, ReadingSource.VOCABULARY
3187 if _ROMAN.match(text.rstrip(".")) is not None:
3188 listed = "vocab:suffix" in tags or "vocab:suffix-word" in tags
3189 return (ReadingKind.GENERATION,
3190 ReadingSource.VOCABULARY if listed else ReadingSource.SHAPE)
3191 if "vocab:suffix-word" in tags:
3192 return (ReadingKind.HONORIFIC if HONORIFIC_WORD_TAG in tags
3193 else ReadingKind.POST_NOMINAL), ReadingSource.VOCABULARY
3194 return (ReadingKind.CREDENTIAL,
3195 ReadingSource.SHAPE if SHAPE_ACRONYM_TAG in tags
3196 else ReadingSource.VOCABULARY)
3197
3198
3199def name_reading(token: WorkToken, role: Role | Deferred) -> PendingReading:
3200 """The name reading of a word a decision read as something else,
3201 landing in `role`: an initial where the word is one letter or
3202 initial-shaped, otherwise a name word, declared by the vocabulary
3203 where a derived marker says so and standing by position where
3204 nothing does."""
3205 text = token.text
3206 # C-level for an ASCII word, so a report pays no frame for the test
3207 if (len(text.rstrip(".")) == 1 or _INITIAL.fullmatch(text)
3208 if text.isascii()
3209 else composed_len(text.rstrip(".")) == 1
3210 or is_initial_shaped(text)):
3211 return PendingReading(ReadingKind.INITIAL, ReadingSource.SHAPE, role)
3212 return PendingReading(ReadingKind.NAME, name_source(token.tags), role)
3213
3214
3215def name_source(tags: Set[str]) -> ReadingSource:
3216 """Why a word reads as a name word: declared by the vocabulary
3217 where a derived marker says so, and by position where nothing
3218 does. `name_reading`'s source, which assemble also asks of a word
3219 the parse read as a name (`Token.reading`)."""
3220 # classify writes the ambiguous class's tag on a word admitted by
3221 # its shape too, so the shape tag says no list declared it
3222 if (not tags.isdisjoint(_DECLARED_NAME_TAGS)
3223 and SHAPE_ACRONYM_TAG not in tags):
3224 return ReadingSource.DECLARED
3225 return ReadingSource.POSITION
3226
3227
3228#: The particle reading of a word read as a name: a particle joins the
3229#: words after it, the last group reading as the family (rules/P2).
3230PARTICLE_READING = PendingReading(ReadingKind.PARTICLE,
3231 ReadingSource.VOCABULARY, Role.FAMILY)
3232#: The same for a patronymic particle (rules/R9): its particle reading
3233#: is a patronymic, joining the parent's name after it.
3234PATRONYMIC_READING = PendingReading(ReadingKind.PATRONYMIC,
3235 ReadingSource.VOCABULARY, Role.FAMILY)
3236
3237
3238def particle_reading(tags: frozenset[str]) -> PendingReading:
3239 """The particle reading of a word read as something else, by the
3240 kind classify tagged it with: a patronymic particle's is
3241 `PATRONYMIC_READING`, any other's `PARTICLE_READING`."""
3242 return PATRONYMIC_READING if PATRONYMIC_TAG in tags else PARTICLE_READING
3243
3244
3245#: The title reading of a word read as a name.
3246TITLE_READING = PendingReading(ReadingKind.TITLE, ReadingSource.VOCABULARY,
3247 Role.TITLE)