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

545 statements  

1"""The group suggester: given names a caller declares to be one group, 

2it suggests ``Policy`` settings, chiefly the name order (``Parser.suggest``). 

3 

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. 

27 

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 

45 

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 

57 

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 

73 

74if TYPE_CHECKING: 

75 from ._parser import Parser 

76 

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"} 

85 

86 

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``. 

93 

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() 

103 

104 

105class Signal(StrEnum): 

106 """The evidence a single name offers about its writer's name order.""" 

107 

108 CAPITALS = "capitals" 

109 TRAILING_INITIAL = "trailing-initial" 

110 PARTICLE = "particle" 

111 SURNAME = "surname" 

112 GIVEN_NAME = "given-name" 

113 PATRONYMIC = "patronymic" 

114 

115 

116_INITIAL_JOINS = re.compile(r"[.-]+") 

117 

118 

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) 

128 

129 

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)) 

139 

140 

141def _has_lower(words: list[str]) -> bool: 

142 return any(c.islower() for w in words for c in w) 

143 

144 

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)) 

155 

156 

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 

171 

172 

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 

177 

178 def is_particle(word: str) -> bool: 

179 return word == word.lower() and _normalize(word) in particles 

180 

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 

186 

187 

188def _listed(word: str, table: frozenset[str]) -> bool: 

189 spelling = fold(word.strip(".")) 

190 return len(spelling) >= 2 and spelling in table 

191 

192 

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) 

196 

197 

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)) 

204 

205 

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)) 

212 

213 

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)) 

222 

223 

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 

246 

247 

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]+") 

252 

253 

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 

280 

281 

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 

303 

304 

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() 

343 

344 

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] = {} 

352 

353 capitals = _capitals(words) 

354 if capitals is not None: 

355 votes[Signal.CAPITALS] = capitals 

356 

357 if _is_initialed(words[-1]) and not _is_initialed(words[0]): 

358 votes[Signal.TRAILING_INITIAL] = FAMILY_FIRST 

359 

360 particle = _particle(words, lexicon) 

361 if particle is not None: 

362 votes[Signal.PARTICLE] = particle 

363 

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 

373 

374 patronymic = _patronymic(words) 

375 if patronymic is not None: 

376 votes[Signal.PATRONYMIC] = patronymic 

377 return votes, False 

378 

379 

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] 

387 

388 

389# -- the group ---------------------------------------------------------- 

390 

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 

397 

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. 

403 

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 

434 

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)} 

439 

440 

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 

445 

446 

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) 

454 

455 

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") 

482 

483 

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) 

494 

495 

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) 

521 

522 

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)) 

526 

527 

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) 

537 

538 

539_NAME_EVIDENCE_EXAMPLE = ( 

540 "NameEvidence('Kim Minjun', {Signal.SURNAME: FAMILY_FIRST})") 

541 

542 

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.""" 

557 

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], ...] 

564 

565 __getstate__ = _guarded_getstate 

566 __setstate__ = _guarded_setstate 

567 

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) 

592 

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})" 

597 

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)) 

602 

603 

604@dataclass(frozen=True, slots=True) 

605class SettingEvidence: 

606 """What the group said about one ``Policy`` setting. 

607 

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.""" 

620 

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 = "" 

633 

634 __getstate__ = _guarded_getstate 

635 __setstate__ = _guarded_setstate 

636 

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)) 

672 

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)") 

679 

680 

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.""" 

690 

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, ...] 

697 

698 __getstate__ = _guarded_getstate 

699 __setstate__ = _guarded_setstate 

700 

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() 

731 

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())})") 

736 

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) 

765 

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 

783 

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 

791 

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)) 

803 

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") 

857 

858 

859 

860def _setting_of(signal: Signal) -> str: 

861 return _PATRONYMIC_RULES if signal is Signal.PATRONYMIC else _NAME_ORDER 

862 

863 

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] 

880 

881 

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.""" 

886 

887 __slots__ = ("names", "contested", "by_name", "by_signal") 

888 

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() 

896 

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 

908 

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 

921 

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) 

938 

939 

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] 

959 

960 

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) 

980 

981 

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) 

996 

997 

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") 

1004 

1005 

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) 

1041 

1042 

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 

1076 

1077 

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))) 

1092 

1093 

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) 

1132 

1133 

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) 

1141 

1142 

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") 

1173 

1174 

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))