Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/nameparser/_suggest.py: 24%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1"""The group suggester: given names a caller declares to be one group,
2it suggests ``Policy`` settings, chiefly the name order (``Parser.suggest``).
4Contracts are stated in prose here, with no doctest blocks. This module
5holds the shared spelling fold so the name tables
6(``config/_order_names.py``, written by ``tools/vocab/order_names.py``)
7and the reader that looks words up in them fold alike, and the per-name
8signals: ``name_votes`` reads one name and returns the votes it casts,
9a dict from ``Signal`` to either an order (``GIVEN_FIRST`` or
10``FAMILY_FIRST``) or, for ``Signal.PATRONYMIC``, the ``PatronymicRule``
11its shape matches. A name is read past its titles at the head and its
12post-nominals at the end, by the base lexicon, with quotes and brackets
13around a word read past too; a name with a comma is read before the
14comma where only post-nominals follow it, and casts nothing otherwise.
15A signal that does not apply casts no vote, so the dict is empty for a
16name of fewer than two words once those are read past. The signals read
17cased evidence in any script: a word with no cased letter never counts
18as capitals (Cyrillic capitals vote, as Latin ones do), a one-letter
19word is never looked up in the name tables, a capitals word of two or
20three letters ending a name that holds a lowercase letter (joined
21initials, `Armitage JO`, or a short surname, `Jason NG`) casts no
22capitals vote and is not looked up as a surname, and a script the
23tables do not hold (Hangul, Han) simply matches nothing. A patronymic
24votes, Latin or Cyrillic, only where the pipeline's rule reads one
25(rules/O1's three name words, rules/O2's four), through the pipeline's
26own predicates, never a copy of them. No signal raises on any string.
28``suggest`` tallies the group's votes and decides two settings
29together, ``patronymic_rules`` first and ``name_order`` second: a
30decided East Slavic rule reorders each name itself and stands down
31under a family-first order, so it decides the order as given-first,
32unless the names' own votes decide family-first, which it never
33overrides (decisions/group-suggestion). Before either it decides
34``initials_as_family`` (rules/O8) from the runs of letters ending the
35group's names, a group property with no per-name vote, and a group
36decided so casts no trailing-initial vote on its order. A setting is
37decided only on the evidence the thresholds below admit; any other
38setting keeps the base's value, so an empty group, or one with no
39votes, gives back a parser that reads exactly as the base does. The
40result is a ``Suggestion``: the ``PolicyPatch`` holding only the
41settings the group changed, a ``SettingEvidence`` per setting
42considered, and a ``NameEvidence`` per input name, in input order.
43"""
44from __future__ import annotations
46import dataclasses
47import re
48import reprlib
49import unicodedata
50import warnings
51from collections import Counter
52from collections.abc import Callable, Iterable, Iterator, Mapping
53from dataclasses import dataclass
54from enum import StrEnum
55from types import MappingProxyType
56from typing import TYPE_CHECKING, cast
58from ._lexicon import Lexicon, _normalize
59from ._pipeline._state import COMMA_CHARS
60from ._pipeline._vocab import (
61 ROMAN, east_slavic_rotates, has_patronymic_ending, is_initial,
62 is_one_case, is_patronymic_marker, is_suffix_lenient, is_title_shaped,
63 reads_as_generation, suffix_as_written,
64)
65from ._policy import (
66 UNSET, PatronymicRule, Policy, PolicyPatch, _order_repr, apply_patch,
67 initials_as_family_may_move,
68)
69from ._types import (
70 FAMILY_FIRST, GIVEN_FIRST, Role, _guarded_getstate, _guarded_setstate,
71)
72from .config._order_names import COMMON_GIVEN_NAMES, ROMANIZED_SURNAMES
74if TYPE_CHECKING:
75 from ._parser import Parser
77# the letters NFKD leaves whole, spelled as an ASCII keyboard writes
78# them
79_KEYBOARD = str.maketrans({
80 "ł": "l", "Ł": "L", "ø": "o", "Ø": "O", "ı": "i", "æ": "ae", "Æ": "AE",
81 "œ": "oe", "Œ": "OE", "ß": "ss", "ẞ": "SS", "đ": "d", "Đ": "D"})
82# a letter u with a diaeresis, decomposed: the pinyin vowel written v on
83# a keyboard (lü -> lv)
84_U_DIAERESIS = {"u\u0308": "v", "U\u0308": "V"}
87def fold(word: str) -> str:
88 """The spelling a word is looked up under in the name tables: lower
89 case, every diacritic dropped, the letters NFKD leaves whole written
90 as an ASCII keyboard writes them (``ł`` as ``l``, ``ø`` as ``o``,
91 ``ı`` as ``i``, ``æ`` as ``ae``, ``œ`` as ``oe``, ``ß`` as ``ss``,
92 ``đ`` as ``d``) and ``ü`` as ``v``.
94 ``Nguyễn`` folds to ``nguyen``, ``Đặng`` to ``dang``, ``Łukasz`` to
95 ``lukasz``, ``Barış`` to ``baris`` and ``Lǚ`` to ``lv``. The tables
96 hold only folded spellings, written with this function, so a reader
97 folds the word first.
98 """
99 text = unicodedata.normalize("NFKD", word).translate(_KEYBOARD)
100 for mark, letter in _U_DIAERESIS.items():
101 text = text.replace(mark, letter)
102 return "".join(c for c in text if not unicodedata.combining(c)).lower()
105class Signal(StrEnum):
106 """The evidence a single name offers about its writer's name order."""
108 CAPITALS = "capitals"
109 TRAILING_INITIAL = "trailing-initial"
110 PARTICLE = "particle"
111 SURNAME = "surname"
112 GIVEN_NAME = "given-name"
113 PATRONYMIC = "patronymic"
116_INITIAL_JOINS = re.compile(r"[.-]+")
119def _is_initialed(word: str) -> bool:
120 """An initial: one letter with an optional period, or single letters
121 joined by periods or hyphens (`J.P.`, `J.-P.`, `J-P`)."""
122 if is_initial(word):
123 return True
124 if "." not in word and "-" not in word:
125 return False
126 chunks = _INITIAL_JOINS.split(word.rstrip("."))
127 return all(chunk and is_initial(chunk + ".") for chunk in chunks)
130def _is_caps_word(word: str) -> bool:
131 """Two or more letters, all of them capitals, and no initial."""
132 letters = [c for c in word if c.isalpha()]
133 # single letters joined by stops or hyphens ('J.-P.', 'J-P') are
134 # initials, however many letters they hold
135 chunks = [c for c in word.replace(".", "-").split("-") if c]
136 paired_initials = len(chunks) > 1 and all(len(c) == 1 for c in chunks)
137 return (len(letters) >= 2 and word.isupper()
138 and not paired_initials and not _is_initialed(word))
141def _has_lower(words: list[str]) -> bool:
142 return any(c.islower() for w in words for c in w)
145def _ends_in_short_capitals(words: list[str]) -> bool:
146 """Whether a name holding a lowercase letter ends in a capitals word
147 of two or three letters: joined initials (`Armitage JO`, `Smith JP`)
148 or a short surname (`Jason NG`), which the word alone cannot tell
149 apart. Such a word casts no capitals vote and is not looked up as a
150 surname; a leading one (`NG Jason`) still votes."""
151 last = words[-1]
152 letters = sum(c.isalpha() for c in last)
153 return (2 <= letters <= 3 and _is_caps_word(last)
154 and _has_lower(words))
157def _capitals(words: list[str]) -> tuple[Role, Role, Role] | None:
158 """A run of capitals words at one end of a name that also holds a
159 lowercase letter reads as the family name, which writers set in
160 capitals."""
161 if not _has_lower(words) or _ends_in_short_capitals(words):
162 return None
163 # a name holding a lowercase letter holds a word that is not all
164 # capitals, so the two ends never both vote by being every word
165 caps = [_is_caps_word(w) for w in words]
166 if caps[0] and not caps[-1]:
167 return FAMILY_FIRST
168 if caps[-1] and not caps[0]:
169 return GIVEN_FIRST
170 return None
173def _particle(words: list[str], lexicon: Lexicon) -> tuple[Role, Role, Role] | None:
174 if len(words) < 3:
175 return None
176 particles = lexicon._sets.particles
178 def is_particle(word: str) -> bool:
179 return word == word.lower() and _normalize(word) in particles
181 first = is_particle(words[0])
182 before_last = is_particle(words[-2])
183 if first == before_last:
184 return None
185 return FAMILY_FIRST if first else GIVEN_FIRST
188def _listed(word: str, table: frozenset[str]) -> bool:
189 spelling = fold(word.strip("."))
190 return len(spelling) >= 2 and spelling in table
193def _at_one_end(words: list[str], table: frozenset[str],
194 skip_last: bool = False) -> tuple[bool, bool]:
195 return _listed(words[0], table), not skip_last and _listed(words[-1], table)
198def _a_listed_name(word: str) -> bool:
199 """A word the name tables list: name evidence, which is never read
200 past as a title or a post-nominal (`Law`, a Cantonese surname and a
201 listed title; `Cem`, a Turkish given name and a listed credential)."""
202 return (_listed(word, ROMANIZED_SURNAMES)
203 or _listed(word, COMMON_GIVEN_NAMES))
206def _is_leading_title(word: str, lexicon: Lexicon) -> bool:
207 """A word the lexicon lists as a title, or one rules/H2's shape
208 reads as a title at the head of a name (`Prof.`, `Insp.`)."""
209 return not _a_listed_name(word) and (
210 _normalize(word) in lexicon._sets.titles
211 or is_title_shaped(word, lexicon))
214def _is_post_nominal(word: str, lexicon: Lexicon) -> bool:
215 """A word after a comma that is a suffix the lexicon lists, or a
216 numeral of two letters or more: is_suffix_lenient, the reading
217 after a comma, which takes a listed one-letter numeral too."""
218 stem = word.rstrip(".")
219 return not _a_listed_name(word) and (
220 is_suffix_lenient(word, lexicon)
221 or (len(stem) > 1 and ROMAN.match(stem) is not None))
224def _ends_in_post_nominal(words: list[str], lexicon: Lexicon,
225 one_case: bool) -> bool:
226 """Whether the last of `words` is a suffix the lexicon lists or a
227 numeral the trailing fork reads as the generation. A one-letter
228 numeral, and a listed acronym written as initials (`J.P.`), need two
229 name words in front of them, as the trailing fork's words-to-spare
230 count asks (rules/S10, rules/S3): `Armitage V` and `Smith J.P.` end
231 in initials."""
232 # the length first: the titles read past at the head can leave no
233 # word at all (`Dr.`, `Jr.`, which H2's shape reads as a title there)
234 if len(words) < 2 or _a_listed_name(words[-1]):
235 return False
236 word = words[-1]
237 stem = word.rstrip(".")
238 if len(words) < 3 and (len(stem) == 1 or _is_initialed(word)):
239 return False
240 listed = suffix_as_written(_normalize(word), word, lexicon)
241 if stem and ROMAN.match(stem) is not None:
242 return reads_as_generation(
243 word, frozenset({"vocab:suffix"}) if listed else frozenset(),
244 words[-2], one_case)
245 return listed
248#: The marks a word is written inside (quotes, brackets), stripped from
249#: its ends before any test.
250_EDGES = "\"'\u2018\u2019\u201a\u201b\u201c\u201d\u201e\u00ab\u00bb\u2039\u203a()[]{}"
251_COMMAS = re.compile("[" + re.escape("".join(sorted(COMMA_CHARS))) + r"\s]+")
254def _name_words(text: str, lexicon: Lexicon) -> tuple[list[str], bool]:
255 """The words the signals read, and whether a comma left the name
256 out. A comma followed by post-nominals alone (`Zhang Wei, PhD`)
257 leaves the part before it; any other comma name is left out, its
258 comma stating its order. Quotes and brackets are stripped from each
259 word, which is composed (NFC), then the titles at the head and the
260 post-nominals at the end (a listed suffix, a numeral read as the
261 generation)."""
262 cut = next((i for i, c in enumerate(text) if c in COMMA_CHARS), None)
263 if cut is not None:
264 tail = [w.strip(_EDGES) for w in _COMMAS.split(text[cut + 1:])]
265 if not all(_is_post_nominal(w, lexicon) for w in tail if w):
266 return [], True
267 text = text[:cut]
268 # composed, so a letter written as base and mark (`oğlu` decomposed)
269 # meets the patterns written composed
270 words = [unicodedata.normalize("NFC", w)
271 for w in (w.strip(_EDGES) for w in text.split()) if w]
272 if not words:
273 return words, False
274 one_case = is_one_case(words)
275 while words and _is_leading_title(words[0], lexicon):
276 del words[0]
277 while _ends_in_post_nominal(words, lexicon, one_case):
278 del words[-1]
279 return words, False
282def _patronymic(words: list[str]) -> PatronymicRule | None:
283 """The rule a name's patronymic votes for, where the pipeline's rule
284 can read it there. rules/O2's four name words, the marker last
285 (family-first) or third (given-first, which the Turkic rule reads
286 too), never first or second; rules/O1's three name words, the
287 ending on the middle word (given-first) or on the last
288 (`east_slavic_rotates`, the family-first shape the rule restores),
289 whatever the first word ends in (a surname may end like a
290 patronymic: `Abramovich Roman Arkadyevich`), and no initial first or in the middle (O1's
291 precedence). A final full stop is read past."""
292 bare = [w.rstrip(".") or w for w in words]
293 if (len(bare) == 4 and not any(map(is_patronymic_marker, bare[:2]))
294 and (is_patronymic_marker(bare[2])
295 or is_patronymic_marker(bare[3]))):
296 return PatronymicRule.TURKIC
297 if (len(bare) == 3
298 and not (_is_initialed(words[0]) or _is_initialed(words[1]))
299 and (has_patronymic_ending(bare[1])
300 or east_slavic_rotates(bare[1], bare[2]))):
301 return PatronymicRule.EAST_SLAVIC
302 return None
305def _letter_run(text: str, readers: tuple[Parser, Parser]) -> str | None:
306 """The letters ending `text` that are evidence of the convention
307 rules/O8 reads, folded and run together (`Lakshmi R. S.` gives
308 ``rs``), or None. A spaced run counts where the rule reads it, asked
309 of the parse itself with the switch on and off (the base's lexicon,
310 the given-first order the rule reads under), so it counts exactly
311 where the rule moves a name. A run written joined with full stops
312 (`B.K.`, `K.C.`) ending the name's words after a name word counts
313 too, though the rule never moves it: the parse makes it the family
314 already (rules/T7), so it is the same convention, and the setting it
315 suggests moves only the spaced runs beside it. Two or three bare
316 capitals (`BK`) do not count: they are written as a short surname is
317 (`Zhi LI`)."""
318 # the cheap text test first: a name it refuses holds no run at all
319 if not initials_as_family_may_move(text):
320 return None
321 on, off = readers
322 family = on.parse(text).family
323 if family != off.parse(text).family:
324 return "".join(_normalize(word).replace(".", "")
325 for word in family.split())
326 # a comma name gives no words (_name_words), so it holds no run
327 words = _name_words(text, readers[0].lexicon)[0]
328 if len(words) < 2 or _is_initialed(words[0]):
329 return None
330 last = words[-1]
331 # joined with full stops alone: a hyphen joins the halves of one
332 # given name (`J.-P.`, `J-H`), which is no family
333 if not ("." in last.rstrip(".") and "-" not in last
334 and _is_initialed(last)):
335 return None
336 letters = [ch for ch in last if ch.isalpha()]
337 # only where the parse makes the word the whole family: an unlisted
338 # dotted credential (`John Smith X.Y.Z.`, rules/S3) is a suffix
339 if len(letters) < 2 or _normalize(
340 readers[1].parse(text).family) != _normalize(last):
341 return None
342 return "".join(letters).lower()
345def _read(text: str, lexicon: Lexicon
346 ) -> tuple[dict[Signal, tuple[Role, Role, Role] | PatronymicRule], bool]:
347 """The votes one name casts, and whether a comma left it out."""
348 words, comma = _name_words(text, lexicon)
349 if len(words) < 2:
350 return {}, comma
351 votes: dict[Signal, tuple[Role, Role, Role] | PatronymicRule] = {}
353 capitals = _capitals(words)
354 if capitals is not None:
355 votes[Signal.CAPITALS] = capitals
357 if _is_initialed(words[-1]) and not _is_initialed(words[0]):
358 votes[Signal.TRAILING_INITIAL] = FAMILY_FIRST
360 particle = _particle(words, lexicon)
361 if particle is not None:
362 votes[Signal.PARTICLE] = particle
364 short_capitals = _ends_in_short_capitals(words)
365 for signal, table, first_order, last_order, skip_last in (
366 (Signal.SURNAME, ROMANIZED_SURNAMES, FAMILY_FIRST, GIVEN_FIRST,
367 short_capitals),
368 (Signal.GIVEN_NAME, COMMON_GIVEN_NAMES, GIVEN_FIRST, FAMILY_FIRST,
369 False)):
370 at_first, at_last = _at_one_end(words, table, skip_last)
371 if at_first != at_last:
372 votes[signal] = first_order if at_first else last_order
374 patronymic = _patronymic(words)
375 if patronymic is not None:
376 votes[Signal.PATRONYMIC] = patronymic
377 return votes, False
380def name_votes(text: str, lexicon: Lexicon
381 ) -> dict[Signal, tuple[Role, Role, Role] | PatronymicRule]:
382 """The votes one name casts; empty for a name of fewer than two
383 words once its titles and post-nominals are read past, and for a
384 comma name, save one whose comma is followed by post-nominals
385 alone."""
386 return _read(text, lexicon)[0]
389# -- the group ----------------------------------------------------------
391#: A vote one signal casts: an order, or the patronymic rule a shape
392#: matches.
393Vote = tuple[Role, Role, Role] | PatronymicRule
394#: A setting's value as SettingEvidence holds it: an order, a set of
395#: patronymic rules, or a switch (initials_as_family).
396SettingValue = tuple[Role, Role, Role] | frozenset[PatronymicRule] | bool
398# The thresholds, chosen with tools/differential/suggest_measure.py
399# so that a group written in its base's own order stays right in at
400# least 95% of draws at every size; the measurements behind each are in
401# decisions/group-suggestion, 2026-11-10 THRESHOLDS and 2026-11-11
402# TRAILING INITIAL.
404#: The fewest names voting for a value that can decide it.
405_MIN_VOTES = 2
406#: The share of the names casting an order vote that one end must hold
407#: STRICTLY MORE THAN to decide the order: more than two thirds, so a
408#: tie, or two names against one, decides nothing.
409_MIN_SHARE = 2 / 3
410#: The share of the names casting any vote that one patronymic rule
411#: (East Slavic or Turkic) must hold strictly more than to decide it,
412#: higher than _MIN_SHARE because a decided East Slavic rule brings a
413#: given-first order with it.
414_MIN_PATRONYMIC_SHARE = 0.8
415#: The fewest names carrying one patronymic rule that can decide it,
416#: besides the share, for the same reason.
417_MIN_PATRONYMIC_VOTES = 5
418#: The fewest names ending in one run of letters (rules/O8) that can
419#: decide initials_as_family, and the share of the names ending in any
420#: run that one run must hold at least: a list written in that
421#: convention repeats its letters (Nepal's `B K` and `K C`), where a
422#: list of middle and family initials or of bylines rarely does
423#: (decisions/group-suggestion, 2026-11-11 LETTER RUNS).
424_MIN_RUN_NAMES = 2
425_MIN_RUN_SHARE = 0.5
426#: The trailing-initial signal casts its votes only where the names it
427#: reads are at least this many and at least _MIN_TRAILING_SHARE of the
428#: group's names: a byline list ends nearly every name in initials,
429#: while a South Asian or Brazilian list ends a minority that way, and
430#: there the letters are the family or a middle and a family initial
431#: (decisions/group-suggestion, 2026-11-11 TRAILING INITIAL, #1254).
432_MIN_TRAILING_NAMES = 3
433_MIN_TRAILING_SHARE = 0.6
435_NAME_ORDER = "name_order"
436_PATRONYMIC_RULES = "patronymic_rules"
437_INITIALS_AS_FAMILY = "initials_as_family"
438_SIGNAL_RANK = {signal: rank for rank, signal in enumerate(Signal)}
441def _end(order: tuple[Role, ...]) -> tuple[Role, Role, Role]:
442 """The end an order writes the family name at, spelled as a vote:
443 ``FAMILY_FIRST`` for either family-first order."""
444 return FAMILY_FIRST if order[0] is Role.FAMILY else GIVEN_FIRST
447def _order_ends(pairs: Iterable[tuple[Signal, Vote]]
448 ) -> frozenset[tuple[Role, Role, Role]]:
449 """The ends a name's order signals voted for: none, one (the name's
450 order vote) or both (the name is contested and casts no order
451 vote)."""
452 return frozenset(vote for signal, vote in pairs # type: ignore[misc]
453 if signal is not Signal.PATRONYMIC)
456def _checked_vote(signal: object, vote: object,
457 owner: str = "NameEvidence") -> tuple[Signal, Vote]:
458 """One (signal, vote) pair, validated: an order signal votes
459 ``GIVEN_FIRST`` or ``FAMILY_FIRST``, the patronymic signal a
460 ``PatronymicRule``. ``owner`` names the type in the message."""
461 signal = Signal(signal) # type: ignore[arg-type] # ValueError names it
462 if signal is Signal.PATRONYMIC:
463 if not isinstance(vote, PatronymicRule):
464 raise TypeError(
465 f"{owner} vote for {signal.value!r} must be a "
466 f"PatronymicRule, got {vote!r}; pass e.g. "
467 f"{{Signal.PATRONYMIC: PatronymicRule.EAST_SLAVIC}}")
468 return signal, vote
469 if not isinstance(vote, (tuple, list)):
470 raise TypeError(
471 f"{owner} vote for {signal.value!r} must be an order, "
472 f"got {vote!r}; pass GIVEN_FIRST or FAMILY_FIRST")
473 order = tuple(vote)
474 for end in (GIVEN_FIRST, FAMILY_FIRST):
475 if order == end:
476 # the shared constant, so a vote compares by identity too
477 return signal, end
478 raise ValueError(
479 f"{owner} vote for {signal.value!r} must name an end, "
480 f"GIVEN_FIRST or FAMILY_FIRST, got {vote!r}; a vote never "
481 f"spells FAMILY_FIRST_GIVEN_LAST, which is FAMILY_FIRST's end")
484def _iterable(value: object, owner: str, form: str, example: str
485 ) -> tuple[object, ...]:
486 """``value`` as a tuple, refusing a bare str, bytes, a mapping or a
487 non-iterable, each of which would iterate into the wrong items."""
488 if (isinstance(value, (str, bytes, bytearray, Mapping))
489 or not isinstance(value, Iterable)):
490 raise TypeError(
491 f"{owner} must be {form}, got {type(value).__name__} "
492 f"{reprlib.repr(value)}; pass e.g. {example}")
493 return tuple(value)
496def _checked_row(row: object, setting: str) -> tuple[Signal, Vote, int]:
497 """One ``by_signal`` row, validated: ``(signal, vote, count)``, its
498 signal one that votes on ``setting``."""
499 if isinstance(row, (str, bytes)) or not isinstance(row, (tuple, list)) \
500 or len(row) != 3:
501 raise TypeError(
502 f"SettingEvidence by_signal rows must be (signal, vote, count) "
503 f"triples, got {reprlib.repr(row)}; pass e.g. "
504 f"(Signal.SURNAME, FAMILY_FIRST, 2)")
505 signal, vote, count = row
506 pair = _checked_vote(signal, vote, "SettingEvidence by_signal")
507 if _setting_of(pair[0]) != setting:
508 raise ValueError(
509 f"SettingEvidence by_signal row {reprlib.repr(row)} is a "
510 f"{pair[0].value!r} vote, which votes on {_setting_of(pair[0])}, "
511 f"not on {setting}; put it in that setting's evidence")
512 if isinstance(count, bool) or not isinstance(count, int):
513 raise TypeError(
514 f"SettingEvidence by_signal count must be an int count of "
515 f"votes, got {count!r}; pass e.g. 2")
516 if count < 0:
517 raise ValueError(
518 f"SettingEvidence by_signal count must not be negative, got "
519 f"{count!r}; a count of votes is 0 or more")
520 return (*pair, count)
523#: The settings a SettingEvidence may be about: the PolicyPatch fields,
524#: which the report reads off the patch by name.
525_SETTINGS = frozenset(f.name for f in dataclasses.fields(PolicyPatch))
528def _label(value: object) -> str:
529 """A vote or a setting's value, as the report and reprs write it."""
530 if isinstance(value, PatronymicRule):
531 return value.value
532 if isinstance(value, frozenset):
533 return ", ".join(sorted(_label(v) for v in value)) or "none"
534 if isinstance(value, tuple):
535 return _order_repr(value)
536 return repr(value)
539_NAME_EVIDENCE_EXAMPLE = (
540 "NameEvidence('Kim Minjun', {Signal.SURNAME: FAMILY_FIRST})")
543@dataclass(frozen=True, slots=True, init=False)
544class NameEvidence:
545 """One input name and the votes it cast, read with the base
546 parser's lexicon. A name with a comma, no words or one word casts
547 none, and a name that casts none never disagrees with the group.
548 Order votes are one per NAME: the end the name's order signals
549 agree on. A name whose order signals point at both ends is
550 contested, casts no order vote and never disagrees on order.
551 ``votes`` keeps every signal's vote all the same, save a
552 trailing-initial vote the group did not cast: too few of its names
553 ending in initials (``Parser.suggest``), or its runs of letters
554 decided ``initials_as_family``, whose letters are its family.
555 ``dataclasses.replace`` keeps the votes unless it is passed
556 ``votes=`` too."""
558 #: The name exactly as the caller passed it.
559 text: str
560 # the votes as (Signal, vote) pairs in Signal's order: a tuple, so
561 # the type stays frozen, hashable and picklable; read it through
562 # the `votes` property
563 _votes: tuple[tuple[Signal, Vote], ...]
565 __getstate__ = _guarded_getstate
566 __setstate__ = _guarded_setstate
568 def __init__(self, text: str,
569 votes: Mapping[Signal, Vote] | None = None, *,
570 _votes: Iterable[tuple[Signal, Vote]] | None = None
571 ) -> None:
572 # `_votes` is the stored field's name, which dataclasses.replace
573 # passes back; `votes`, where given, wins, so
574 # replace(evidence, votes=...) replaces them
575 if votes is None and _votes is not None:
576 votes = dict(_votes)
577 if not isinstance(text, str):
578 raise TypeError(
579 f"NameEvidence text must be a str, got "
580 f"{type(text).__name__} {reprlib.repr(text)}; pass e.g. "
581 f"{_NAME_EVIDENCE_EXAMPLE}")
582 if not isinstance(votes, Mapping):
583 raise TypeError(
584 f"NameEvidence votes must be a mapping of Signal to vote, "
585 f"got {type(votes).__name__} {reprlib.repr(votes)}; pass "
586 f"e.g. {_NAME_EVIDENCE_EXAMPLE}")
587 pairs = tuple(sorted(
588 (_checked_vote(signal, vote) for signal, vote in votes.items()),
589 key=lambda pair: _SIGNAL_RANK[pair[0]]))
590 object.__setattr__(self, "text", text)
591 object.__setattr__(self, "_votes", pairs)
593 def __repr__(self) -> str:
594 votes = "".join(f", {signal.value}={_label(vote)}"
595 for signal, vote in self._votes)
596 return f"NameEvidence({self.text!r}{votes})"
598 @property
599 def votes(self) -> Mapping[Signal, Vote]:
600 """The votes this name cast, keyed by signal; read-only."""
601 return MappingProxyType(dict(self._votes))
604@dataclass(frozen=True, slots=True)
605class SettingEvidence:
606 """What the group said about one ``Policy`` setting.
608 ``value`` is the setting's value under the suggested parser: the
609 decided value where ``decided`` is true, else the base's. A decided
610 value equal to the base's is not in the patch, which holds only
611 changes, but is still ``decided``. The four counts are of NAMES and
612 add up to the group's size: ``votes_for`` the names whose vote is
613 for ``value``, ``votes_against`` the names whose vote is for
614 another value, ``contested`` the names whose signals point both
615 ways on this setting (and so cast no vote), and ``no_evidence`` the
616 names with no signal on it. ``by_signal`` breaks the signals' own
617 votes down as ``(signal, vote, count)`` rows, so a name two signals
618 read appears in two of them. ``note``, where it is not empty, says
619 why the setting was decided or left undecided as it was."""
621 #: The ``Policy`` field this evidence is about.
622 setting: str
623 #: The field's value under the suggested parser.
624 value: SettingValue
625 #: Whether the group's votes decided the value.
626 decided: bool
627 votes_for: int
628 votes_against: int
629 contested: int
630 no_evidence: int
631 by_signal: tuple[tuple[Signal, Vote, int], ...]
632 note: str = ""
634 __getstate__ = _guarded_getstate
635 __setstate__ = _guarded_setstate
637 def __post_init__(self) -> None:
638 if not isinstance(self.setting, str):
639 raise TypeError(
640 f"SettingEvidence setting must be a Policy field name "
641 f"(str), got {self.setting!r}; pass e.g. 'name_order'")
642 if self.setting not in _SETTINGS:
643 raise ValueError(
644 f"SettingEvidence setting must name a Policy field, got "
645 f"{self.setting!r}; pass one of "
646 f"{', '.join(sorted(_SETTINGS))}")
647 if not isinstance(self.decided, bool):
648 raise TypeError(
649 f"SettingEvidence decided must be a bool, got "
650 f"{self.decided!r}; pass True or False")
651 for name in ("votes_for", "votes_against", "contested",
652 "no_evidence"):
653 count = getattr(self, name)
654 if isinstance(count, bool) or not isinstance(count, int):
655 raise TypeError(
656 f"SettingEvidence {name} must be an int count of "
657 f"names, got {count!r}; pass e.g. 0")
658 if count < 0:
659 raise ValueError(
660 f"SettingEvidence {name} must not be negative, got "
661 f"{count!r}; a count of names is 0 or more")
662 if not isinstance(self.note, str):
663 raise TypeError(
664 f"SettingEvidence note must be a str, got {self.note!r}; "
665 f"pass '' for no note")
666 rows = _iterable(self.by_signal, "SettingEvidence by_signal",
667 "an iterable of (signal, vote, count) rows",
668 "((Signal.SURNAME, FAMILY_FIRST, 2),) or ()")
669 object.__setattr__(
670 self, "by_signal",
671 tuple(_checked_row(row, self.setting) for row in rows))
673 def __repr__(self) -> str:
674 state = "decided" if self.decided else "undecided"
675 return (f"SettingEvidence({self.setting}={_label(self.value)}, "
676 f"{state}, {self.votes_for} for, {self.votes_against} "
677 f"against, {self.contested} contested, {self.no_evidence} "
678 f"no evidence)")
681@dataclass(frozen=True, slots=True)
682class Suggestion:
683 """The settings a group of names suggests: build it with
684 :meth:`Parser.suggest`. ``patch`` holds only the settings the group
685 decided and that differ from the base's; ``parser`` is the base
686 with the patch applied; ``evidence`` holds one
687 :class:`SettingEvidence` per setting considered, and ``names`` one
688 :class:`NameEvidence` per input name, in input order, blanks and
689 duplicates included. ``str()`` is a readable report."""
691 #: The parser ``suggest`` was called on.
692 base: Parser
693 #: The settings the group decided that differ from the base's.
694 patch: PolicyPatch
695 evidence: tuple[SettingEvidence, ...]
696 names: tuple[NameEvidence, ...]
698 __getstate__ = _guarded_getstate
699 __setstate__ = _guarded_setstate
701 def __post_init__(self) -> None:
702 # Parser at call time: _parser imports this module at call time
703 # too, in Parser.suggest
704 from ._parser import Parser
705 if not isinstance(self.base, Parser):
706 raise TypeError(
707 f"Suggestion base must be a Parser, got "
708 f"{type(self.base).__name__} {reprlib.repr(self.base)}; "
709 f"build a Suggestion with parser.suggest(names)")
710 if not isinstance(self.patch, PolicyPatch):
711 raise TypeError(
712 f"Suggestion patch must be a PolicyPatch, got "
713 f"{reprlib.repr(self.patch)}; pass e.g. PolicyPatch() or "
714 f"PolicyPatch(name_order=FAMILY_FIRST)")
715 for name, cls in (("evidence", SettingEvidence),
716 ("names", NameEvidence)):
717 items = _iterable(
718 getattr(self, name), f"Suggestion {name}",
719 f"a tuple of {cls.__name__}", f"() or a tuple of "
720 f"{cls.__name__} items")
721 for item in items:
722 if not isinstance(item, cls):
723 raise TypeError(
724 f"Suggestion {name} must hold {cls.__name__} "
725 f"items, got {type(item).__name__} "
726 f"{reprlib.repr(item)}; pass a tuple of "
727 f"{cls.__name__}, or build a Suggestion with "
728 f"parser.suggest(names)")
729 object.__setattr__(self, name, items)
730 self._check_against_the_patch()
732 def __repr__(self) -> str:
733 # bounded: the patch and counts, never a name
734 return (f"Suggestion({self.patch!r}, names={len(self.names)}, "
735 f"disagreeing={len(self.disagreeing())})")
737 def __str__(self) -> str:
738 noun = "name" if len(self.names) == 1 else "names"
739 lines = [f"Suggested for {len(self.names)} {noun} "
740 f"(base: {self.base!r}):"]
741 for e in self.evidence:
742 if e.decided:
743 changed = getattr(self.patch, e.setting) is not UNSET
744 state = _label(e.value) + ("" if changed else " (as the base)")
745 else:
746 state = f"unchanged ({_label(e.value)})"
747 if e.votes_for or e.votes_against or e.contested:
748 contested = (f"{e.contested} contested, "
749 if e.contested else "")
750 counts = (f"{e.votes_for} for, {e.votes_against} against, "
751 f"{contested}{e.no_evidence} no evidence")
752 else:
753 counts = "no evidence"
754 lines.append(f" {e.setting} {state} {counts}")
755 for signal, vote, count in e.by_signal:
756 lines.append(f" {signal.value} {_label(vote)} {count}")
757 if e.note:
758 lines.append(f" {e.note}")
759 against = self.disagreeing()
760 if against:
761 shown = ", ".join(n.text for n in against[:5])
762 more = f", and {len(against) - 5} more" if len(against) > 5 else ""
763 lines.append(f" disagreeing ({len(against)}): {shown}{more}")
764 return "\n".join(lines)
766 @property
767 def parser(self) -> Parser:
768 """The base parser with :attr:`patch` applied: the same lexicon
769 and segmenter, and the base's policy for every setting the
770 group did not change. It is built afresh on each access, so
771 bind it once (``p = s.parser``) before parsing in a loop."""
772 # A new construction site for Parser (AGENTS.md's validation
773 # bullet): a warning Parser.__post_init__ gives here is passed
774 # on from the caller's frame only where the base, built the
775 # same way, does not give it too -- the base's own warnings
776 # reached its caller when it was built.
777 def built(policy: Policy
778 ) -> tuple[Parser, list[warnings.WarningMessage]]:
779 with warnings.catch_warnings(record=True) as caught:
780 warnings.simplefilter("always")
781 parser = dataclasses.replace(self.base, policy=policy)
782 return parser, caught
784 _, before = built(self.base.policy)
785 parser, after = built(apply_patch(self.base.policy, self.patch))
786 seen = {(w.category, str(w.message)) for w in before}
787 for w in after:
788 if (w.category, str(w.message)) not in seen:
789 warnings.warn(w.message, w.category, stacklevel=2)
790 return parser
792 def disagreeing(self) -> tuple[NameEvidence, ...]:
793 """The names whose vote is against a decided setting's value,
794 in input order. A setting left undecided has no answer to
795 disagree with; a name that cast no vote, or whose order signals
796 are contested, never disagrees on order; and a name carrying an
797 East Slavic patronymic never disagrees on order where the East
798 Slavic rule is decided, since the rule reorders it."""
799 decided = {e.setting: e.value for e in self.evidence if e.decided}
800 if not decided:
801 return ()
802 return tuple(n for n in self.names if _disagrees(n, decided))
804 def _check_against_the_patch(self) -> None:
805 """The evidence, the patch and the names agree: one evidence
806 per setting, a decided value the suggested parser's own, an
807 undecided setting out of the patch, every patch field a decided
808 change from the base, and every setting's counts adding up to
809 the names."""
810 settings = Counter(e.setting for e in self.evidence)
811 for setting, count in settings.items():
812 if count > 1:
813 raise ValueError(
814 f"Suggestion evidence holds {setting!r} more than "
815 f"once ({count} times); pass one SettingEvidence per "
816 f"setting")
817 policy = apply_patch(self.base.policy, self.patch)
818 decided = set()
819 size = len(self.names)
820 for e in self.evidence:
821 total = (e.votes_for + e.votes_against + e.contested
822 + e.no_evidence)
823 if total != size:
824 raise ValueError(
825 f"Suggestion evidence for {e.setting} counts names "
826 f"that add up to {total}, not the {size} names in "
827 f"names; each name is for, against, contested or of "
828 f"no evidence on every setting")
829 if e.decided:
830 decided.add(e.setting)
831 if e.value != getattr(policy, e.setting):
832 raise ValueError(
833 f"Suggestion's decided {e.setting} value "
834 f"{_label(e.value)} is not the suggested parser's "
835 f"{_label(getattr(policy, e.setting))}; set the "
836 f"patch to the decided value, or leave it out "
837 f"where the value is the base's")
838 elif getattr(self.patch, e.setting) is not UNSET:
839 raise ValueError(
840 f"Suggestion patch sets {e.setting}, which its "
841 f"evidence leaves undecided; mark the evidence "
842 f"decided, or leave {e.setting} out of the patch")
843 for f in dataclasses.fields(PolicyPatch):
844 if getattr(self.patch, f.name) is UNSET:
845 continue
846 if f.name not in decided:
847 raise ValueError(
848 f"Suggestion patch sets {f.name}, which no decided "
849 f"SettingEvidence holds; add that evidence, or leave "
850 f"{f.name} out of the patch")
851 if getattr(policy, f.name) == getattr(self.base.policy, f.name):
852 raise ValueError(
853 f"Suggestion patch sets {f.name} to the base's own "
854 f"value {_label(getattr(policy, f.name))}; a patch "
855 f"holds only changes, so leave it out and keep the "
856 f"evidence decided")
860def _setting_of(signal: Signal) -> str:
861 return _PATRONYMIC_RULES if signal is Signal.PATRONYMIC else _NAME_ORDER
864def _disagrees(name: NameEvidence, decided: Mapping[str, object]) -> bool:
865 """Whether one name's votes contradict a decided setting."""
866 patronymic = dict(name._votes).get(Signal.PATRONYMIC)
867 rules = decided.get(_PATRONYMIC_RULES)
868 if rules is not None and patronymic is not None:
869 if patronymic not in rules: # type: ignore[operator]
870 return True
871 order = decided.get(_NAME_ORDER)
872 if order is None:
873 return False
874 if (rules is not None and patronymic is PatronymicRule.EAST_SLAVIC
875 and patronymic in rules # type: ignore[operator]
876 and _end(order) == GIVEN_FIRST): # type: ignore[arg-type]
877 return False
878 ends = _order_ends(name._votes)
879 return len(ends) == 1 and next(iter(ends)) != _end(order) # type: ignore[arg-type]
882class _Tally:
883 """The group's votes on one setting, one per name, gathered in one
884 pass. A plain class rather than a dataclass: it is never pickled or
885 returned."""
887 __slots__ = ("names", "contested", "by_name", "by_signal")
889 def __init__(self) -> None:
890 self.names = 0
891 self.contested = 0
892 #: names per value voted for
893 self.by_name: Counter[Vote] = Counter()
894 #: signal votes per (signal, value)
895 self.by_signal: Counter[tuple[Signal, Vote]] = Counter()
897 def add(self, pairs: list[tuple[Signal, Vote]]) -> None:
898 """One name's votes on this setting: its signals' votes, and
899 one name vote where they agree."""
900 self.names += 1
901 for pair in pairs:
902 self.by_signal[pair] += 1
903 values = {vote for _, vote in pairs}
904 if len(values) == 1:
905 self.by_name[values.pop()] += 1
906 elif values:
907 self.contested += 1
909 def winner(self) -> Vote | None:
910 """The value whose names are at least _MIN_VOTES and strictly
911 more than _MIN_SHARE of the names that cast a vote, if one
912 is."""
913 counts = self.by_name
914 total = sum(counts.values())
915 if not counts:
916 return None
917 vote, count = max(counts.items(), key=lambda item: item[1])
918 if count < _MIN_VOTES or count <= _MIN_SHARE * total:
919 return None
920 return vote
922 def evidence(self, setting: str, value: SettingValue, decided: bool,
923 is_for: Callable[[Vote], bool], note: str = ""
924 ) -> SettingEvidence:
925 """Evidence for ``value``; ``is_for`` says which name votes are
926 for it."""
927 counts = self.by_name
928 votes_for = sum(c for v, c in counts.items() if is_for(v))
929 rows = tuple(sorted(
930 ((signal, vote, count)
931 for (signal, vote), count in self.by_signal.items()),
932 key=lambda row: (_SIGNAL_RANK[row[0]], _label(row[1]))))
933 voters = sum(counts.values())
934 return SettingEvidence(
935 setting, value, decided, votes_for, voters - votes_for,
936 self.contested, self.names - voters - self.contested, rows,
937 note)
940def _patronymic_winner(tally: _Tally, any_voters: int
941 ) -> PatronymicRule | None:
942 """The rule whose names are strictly more than _MIN_PATRONYMIC_SHARE
943 of the names that cast any vote, and at least _MIN_PATRONYMIC_VOTES
944 of them. A
945 name whose order signals are contested still counts among the names
946 that cast a vote: it carries evidence, and leaving it out moved no
947 measured group by more than a point. A name casts at most one
948 patronymic vote, so its votes count names: a share of the group,
949 not of the patronymic votes alone, so one patronymic among many
950 given-first names decides nothing."""
951 counts = tally.by_name
952 if not counts:
953 return None
954 rule, count = max(counts.items(), key=lambda item: item[1])
955 if (count < _MIN_PATRONYMIC_VOTES
956 or count <= _MIN_PATRONYMIC_SHARE * any_voters):
957 return None
958 return rule # type: ignore[return-value]
961def _decide_patronymic(tally: _Tally, base_rules: frozenset[PatronymicRule],
962 any_voters: int
963 ) -> tuple[SettingEvidence, dict[str, object], bool]:
964 """Decide ``patronymic_rules``: the evidence, the patch fields, and
965 whether the decided rule needs a given-first order."""
966 rule = _patronymic_winner(tally, any_voters)
967 if rule is PatronymicRule.EAST_SLAVIC:
968 # the rule reorders a family-first East Slavic name itself, and
969 # stands down under a declared family-first order, so the group
970 # gets the rule with a given-first order (what locales.RU ships)
971 value = base_rules | {rule}
972 fields: dict[str, object] = (
973 {} if rule in base_rules else {_PATRONYMIC_RULES: frozenset({rule})})
974 return (tally.evidence(_PATRONYMIC_RULES, value, True,
975 lambda v: v in value), fields, True)
976 if rule is PatronymicRule.TURKIC:
977 return _decide_turkic(tally, base_rules)
978 return (tally.evidence(_PATRONYMIC_RULES, base_rules, False,
979 lambda v: v in base_rules), {}, False)
982def _decide_turkic(tally: _Tally, base_rules: frozenset[PatronymicRule]
983 ) -> tuple[SettingEvidence, dict[str, object], bool]:
984 """A group whose patronymics are Turkic gets the rule alone. Unlike
985 the East Slavic rule it reads a name written either way round
986 (``Mamedov Ali Huseyn oglu`` and ``Ali Huseyn oglu Mamedov`` both
987 read family Mamedov), so it forces no order: the order is decided
988 from the names' own votes (decisions/group-suggestion, 2026-11-10
989 TURKIC)."""
990 rule = PatronymicRule.TURKIC
991 value = base_rules | {rule}
992 fields: dict[str, object] = (
993 {} if rule in base_rules else {_PATRONYMIC_RULES: frozenset({rule})})
994 return (tally.evidence(_PATRONYMIC_RULES, value, True,
995 lambda v: v in value), fields, False)
998def _comma_note(left_out: int) -> str:
999 if not left_out:
1000 return ""
1001 noun = "name with a comma was" if left_out == 1 else "names with a comma were"
1002 return (f"{left_out} {noun} left out: a comma states its own order, "
1003 f"so only a comma followed by post-nominals alone is read past")
1006def _decide_order(tally: _Tally, base_order: tuple[Role, Role, Role],
1007 rule_needs_given_first: bool, comma_left_out: int,
1008 trailing_note: str = ""
1009 ) -> tuple[SettingEvidence, dict[str, object]]:
1010 """Decide ``name_order`` in either direction. A family-first answer
1011 keeps the base's family-first spelling where the base has one. A
1012 decided East Slavic rule decides the order as given-first, since
1013 the rule reorders each name and stands down under a family-first
1014 order, unless the names' own votes decide family-first: a decided
1015 order is never overridden, and the rule then stands down."""
1016 notes = [_comma_note(comma_left_out), trailing_note]
1017 end = tally.winner()
1018 if rule_needs_given_first and end != FAMILY_FIRST:
1019 fields: dict[str, object] = (
1020 {} if base_order == GIVEN_FIRST else {_NAME_ORDER: GIVEN_FIRST})
1021 notes.insert(0, "the East Slavic rule reorders each name; it needs "
1022 "a given-first order")
1023 return (tally.evidence(
1024 _NAME_ORDER, GIVEN_FIRST, True, lambda v: v == GIVEN_FIRST,
1025 note="; ".join(n for n in notes if n)), fields)
1026 if rule_needs_given_first:
1027 notes.insert(0, "the East Slavic rule is decided too, but the names' "
1028 "order votes decide a family-first order, under "
1029 "which the rule stands down")
1030 note = "; ".join(n for n in notes if n)
1031 if end is None:
1032 return (tally.evidence(_NAME_ORDER, base_order, False,
1033 lambda v: v == _end(base_order), note), {})
1034 # the order tally holds orders alone
1035 order = base_order if _end(base_order) == end else cast(
1036 "tuple[Role, Role, Role]", end)
1037 fields = (
1038 {} if order == base_order else {_NAME_ORDER: order})
1039 return (tally.evidence(_NAME_ORDER, order, True,
1040 lambda v: v == end, note), fields)
1043def _texts(names: Iterable[str]) -> Iterator[str]:
1044 """The names, checked as they are consumed: one pass, so a
1045 generator is read once."""
1046 example = 'parser.suggest(["Priem Jason", "Kim Minjun"])'
1047 if isinstance(names, str):
1048 raise TypeError(
1049 f"suggest() takes an iterable of names, not a bare string: "
1050 f"{names!r}. Wrap one name in a list: "
1051 f"parser.suggest([{names!r}])")
1052 if isinstance(names, (bytes, bytearray, memoryview)):
1053 raise TypeError(
1054 f"suggest() takes an iterable of str, not "
1055 f"{type(names).__name__} -- decode first, e.g. "
1056 f"raw.decode('utf-8')")
1057 if isinstance(names, Mapping):
1058 raise TypeError(
1059 f"suggest() takes an iterable of names, not a mapping "
1060 f"({type(names).__name__}). A mapping yields only its keys; "
1061 f"pass the names themselves, e.g. list(mapping) or "
1062 f"list(mapping.values()): {example}")
1063 try:
1064 iterator = iter(names)
1065 except TypeError:
1066 raise TypeError(
1067 f"suggest() takes an iterable of names, got {names!r}: "
1068 f"{example}") from None
1069 for index, text in enumerate(iterator):
1070 if not isinstance(text, str):
1071 raise TypeError(
1072 f"suggest() takes an iterable of str; item {index} is "
1073 f"{text!r} ({type(text).__name__}). Pass each name as a "
1074 f"string: {example}")
1075 yield text
1078def _letter_readers(parser: Parser) -> tuple[Parser, Parser]:
1079 """The base with the switch on and off, every other setting the
1080 base's own save the fold of every middle into the family (rules/O3),
1081 which takes the letters there too and so would hide the run the
1082 switch reads. Asked only of a base reading names given first, where
1083 the rule acts."""
1084 policy = dataclasses.replace(parser.policy, middle_as_family=False)
1085 with warnings.catch_warnings():
1086 # the base's own construction warnings reached its caller
1087 warnings.simplefilter("ignore")
1088 return (dataclasses.replace(parser, policy=dataclasses.replace(
1089 policy, initials_as_family=True)),
1090 dataclasses.replace(parser, policy=dataclasses.replace(
1091 policy, initials_as_family=False)))
1094def _decide_letters(runs: list[str | None], base_value: bool,
1095 family_first: bool
1096 ) -> tuple[SettingEvidence, dict[str, object]]:
1097 """Decide ``initials_as_family``: true where at least _MIN_RUN_NAMES
1098 names end in one run of letters and it is at least _MIN_RUN_SHARE of
1099 the names ending in any run. A group never decides it false: no
1100 name is evidence that letters are NOT a family. Nor does it decide
1101 anything under a base reading names family first (`family_first`),
1102 where rules/O8 stands down, so the switch would change no name and
1103 no name was read for a run."""
1104 counts = Counter(run for run in runs if run)
1105 carrying = sum(counts.values())
1106 if family_first:
1107 return (SettingEvidence(
1108 _INITIALS_AS_FAMILY, base_value, False, 0, 0, 0, len(runs), (),
1109 "not read: the base reads names family first, where letters "
1110 "ending a name are never taken as its family"), {})
1111 if not counts:
1112 return (SettingEvidence(_INITIALS_AS_FAMILY, base_value, False,
1113 0, 0, 0, len(runs), ()), {})
1114 run, top = counts.most_common(1)[0]
1115 decided = top >= _MIN_RUN_NAMES and top >= _MIN_RUN_SHARE * carrying
1116 value = True if decided else base_value
1117 # a name ending in a run is evidence FOR the switch, whatever the
1118 # group decided; the others carry none
1119 votes_for, votes_against = (carrying, 0) if value else (0, carrying)
1120 if top >= _MIN_RUN_NAMES:
1121 note = (f"{top} of the {carrying} names ending in a run of "
1122 f"letters end in {' '.join(run.upper())}")
1123 else:
1124 verb = "ends" if carrying == 1 else "end"
1125 note = (f"{carrying} of {len(runs)} names {verb} in a run of "
1126 f"letters, none repeated")
1127 fields: dict[str, object] = (
1128 {_INITIALS_AS_FAMILY: True} if decided and not base_value else {})
1129 return (SettingEvidence(_INITIALS_AS_FAMILY, value, decided, votes_for,
1130 votes_against, 0, len(runs) - carrying, (),
1131 note), fields)
1134def _counts_as_initialed(votes: Mapping[Signal, Vote]) -> bool:
1135 """Whether a name counts toward the trailing-initial share: its
1136 trailing initial is its only order evidence or agrees with the rest
1137 of it, so a name whose listed given name contradicts it (`Anna R S`)
1138 does not count."""
1139 return (Signal.TRAILING_INITIAL in votes
1140 and len(_order_ends(votes.items())) == 1)
1143def _trailing_initials_vote(read: list[tuple[str, dict[Signal, Vote], bool]],
1144 lexicon: Lexicon) -> tuple[bool, str]:
1145 """Whether the group's trailing-initial votes are cast, and the note
1146 saying so where they are not. One rule at every count: where any
1147 name casts one, they are cast only where at least _MIN_TRAILING_NAMES
1148 names, and _MIN_TRAILING_SHARE of the group's names, count as
1149 initialed (_counts_as_initialed). The group is the names that hold
1150 a word with a letter or a digit once their titles and post-nominals
1151 are read past, as _name_words reads them: a blank, a mark, a
1152 zero-width character alone (`\u200b`), a name of nothing but titles
1153 and a name a comma left out (which _name_words gives no word) are
1154 no names of the group."""
1155 if not any(Signal.TRAILING_INITIAL in votes for _, votes, _ in read):
1156 return True, ""
1157 group = initialed = 0
1158 for text, votes, _ in read:
1159 words = _name_words(text, lexicon)[0]
1160 if not any(c.isalnum() for word in words for c in word):
1161 continue
1162 group += 1
1163 initialed += _counts_as_initialed(votes)
1164 if (initialed >= _MIN_TRAILING_NAMES
1165 and initialed >= _MIN_TRAILING_SHARE * group):
1166 return True, ""
1167 verb = "ends" if initialed == 1 else "end"
1168 return False, (
1169 f"{initialed} of the group's {group} names {verb} in initials no "
1170 f"other signal contradicts, too few for the trailing-initial "
1171 f"signal to vote: it votes where at least {_MIN_TRAILING_NAMES} "
1172 f"names and {_MIN_TRAILING_SHARE:.0%} of the group do")
1175def suggest(parser: Parser, names: Iterable[str]) -> Suggestion:
1176 """The settings a group of names suggests, with ``parser`` as the
1177 base: see :meth:`Parser.suggest`."""
1178 tallies = {_NAME_ORDER: _Tally(), _PATRONYMIC_RULES: _Tally()}
1179 evidence: list[NameEvidence] = []
1180 any_voters = comma_left_out = 0
1181 texts = list(_texts(names))
1182 family_first = parser.policy.name_order != GIVEN_FIRST
1183 if family_first:
1184 runs: list[str | None] = [None] * len(texts)
1185 else:
1186 readers = _letter_readers(parser)
1187 runs = [_letter_run(text, readers) for text in texts]
1188 letters, letter_fields = _decide_letters(
1189 runs, parser.policy.initials_as_family, family_first)
1190 letters_are_family = letters.decided and letters.value is True
1191 read = [(text, *_read(text, parser.lexicon)) for text in texts]
1192 trailing_votes, trailing_note = _trailing_initials_vote(
1193 read, parser.lexicon)
1194 if letters_are_family:
1195 # the group's letters are its family (rules/O8), so a name
1196 # ending in letters is no evidence of a family-first order
1197 trailing_votes, trailing_note = False, (
1198 "the trailing-initial signal casts no vote: the group's "
1199 "letters are its family (initials_as_family)")
1200 set_aside = 0
1201 for text, votes, comma in read:
1202 # counted before a set-aside vote leaves it: a name ending in
1203 # initials carries evidence all the same, and leaving it out of
1204 # the patronymic share's denominator lets one rule win a group
1205 # of family-first names written with a patronymic initial
1206 # (`Popov Ivan I.`, PatentsView's Russian inventors)
1207 any_voters += bool(votes)
1208 if not trailing_votes and Signal.TRAILING_INITIAL in votes:
1209 del votes[Signal.TRAILING_INITIAL]
1210 set_aside += not votes
1211 evidence.append(NameEvidence(text, votes))
1212 comma_left_out += comma
1213 per_setting: dict[str, list[tuple[Signal, Vote]]] = {
1214 setting: [] for setting in tallies}
1215 for signal, vote in votes.items():
1216 per_setting[_setting_of(signal)].append((signal, vote))
1217 for setting, tally in tallies.items():
1218 tally.add(per_setting[setting])
1219 policy = parser.policy
1220 patronymic, patronymic_fields, settled = _decide_patronymic(
1221 tallies[_PATRONYMIC_RULES], policy.patronymic_rules, any_voters)
1222 if set_aside and any(Signal.PATRONYMIC in n.votes for n in evidence):
1223 # the evidence says what its share was taken of: the names a
1224 # set-aside vote left with none still count among those casting
1225 # one, and show as no evidence
1226 counted = ("1 name whose only vote was a set-aside trailing "
1227 "initial counts" if set_aside == 1 else
1228 f"{set_aside} names whose only vote was a set-aside "
1229 f"trailing initial count")
1230 patronymic = dataclasses.replace(patronymic, note="; ".join(
1231 n for n in (patronymic.note,
1232 f"{counted} among the names casting a vote, the "
1233 f"share's denominator") if n))
1234 order, order_fields = _decide_order(
1235 tallies[_NAME_ORDER], policy.name_order, settled, comma_left_out,
1236 trailing_note)
1237 fields = {**order_fields, **patronymic_fields, **letter_fields}
1238 patch = PolicyPatch(**fields) # type: ignore[arg-type]
1239 return Suggestion(parser, patch, (order, patronymic, letters),
1240 tuple(evidence))