Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/nameparser/_lexicon.py: 23%

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

838 statements  

1"""Immutable vocabulary configuration: a Lexicon holds readings, what 

2each word may be read as, and compiles the sets a parse reads from them 

3(3.0's 4i-b; decisions/vocabulary-model, 2026-11-02 #754). 

4 

5Layering: may import nameparser.config DATA modules as the single 

6source of vocabulary -- never the nameparser.config package itself -- 

7and nameparser._reading_index, the shared compiler and the index's 

8reader, at call time only: tools/vocab/build.py loads this module by 

9path. Enforced by tests/v2/test_layering.py. 

10""" 

11from __future__ import annotations 

12 

13import dataclasses 

14import functools 

15import sys 

16import unicodedata 

17import warnings 

18from collections.abc import Iterable, Mapping, Set 

19from dataclasses import dataclass, field 

20from types import FrameType, MappingProxyType, ModuleType 

21from typing import TYPE_CHECKING, Any, NamedTuple, cast 

22 

23#: Vocabulary set fields, in declaration order. add()/remove() operate 

24#: on exactly these and reject capitalization_exceptions (its entries 

25#: are pairs -- use dataclasses.replace); __or__ unions these AND merges 

26#: capitalization_exceptions right-biased. 

27_VOCAB_FIELDS = ( 

28 "titles", "given_name_titles", "suffix_acronyms", "suffix_words", 

29 "suffix_acronyms_ambiguous", "particles", "particles_ambiguous", 

30 "conjunctions", "conjunctions_ambiguous", "bound_given_names", 

31 "given_name_abbreviations", 

32 "maiden_markers", "surnames", "honorific_tails", "after_given_titles", 

33 "suffix_words_ambiguous", "agnomens", "titles_ambiguous", 

34 "title_modifiers", "title_styles", "title_phrases", "given_names", 

35) 

36 

37#: The metadata key a Lexicon field added after `Lexicon` first shipped 

38#: carries, naming the release that added it, with a default: a pickle 

39#: written before that release lacks the field and loads with the 

40#: default. The same string as `_types.ADDED_IN`, the key the other 

41#: frozen types' shared guard reads; layering keeps this module from 

42#: importing `_types`, so tests/v2/test_lexicon.py holds the two equal. 

43_ADDED_IN = "nameparser.added_in" 

44 

45#: Every character the parser reads as a full stop at a WORD'S EDGE 

46#: (#322/#323): the ASCII period, the fullwidth FULL STOP U+FF0E, the 

47#: IDEOGRAPHIC FULL STOP U+3002 and its halfwidth form U+FF61. One 

48#: string, four readers -- the lookup fold below, _vocab's script 

49#: classification fold, _script_segment's surname site and honorific 

50#: tail match -- so that "which characters are a period" is answered 

51#: once; the bundle's fifth site, _pieces' opening-abbreviation shape, 

52#: reads no stop set at all but the no-initials repertoire 

53#: (_policy._NO_INITIALS) that licenses every reader here. NFKC is 

54#: NOT a substitute for listing them: it folds U+FF0E to '.' and 

55#: U+FF61 to U+3002, and leaves U+3002 as it is. 

56#: Not every period test reads this set, and the ones that do not are 

57#: named so the claim is checkable: the interior-period shapes -- the 

58#: dotted acronym 'M.A.' and the split 'Ph. D.' (_vocab) -- and FOUR 

59#: ASCII-only edge tests on Latin shapes, across THREE modules -- the 

60#: initial 'J.' (_vocab), the bracketed credential '(Mgr.)' and the 

61#: word-internal apostrophe rule (_extract), and the trailing 

62#: middle-initial carve-out 'V.' (_assign) -- stay ASCII. That is a 

63#: fact about those TESTS and not about this set's reach: the fold 

64#: below is script-agnostic, so a LATIN word wearing a wide stop 

65#: reaches vocabulary the ASCII veto would have taken it out of. 

66#: 'V。' folds to 'v' and does not end in the ASCII period the 

67#: trailing-suffix carve-out tests (_assign, not is_initial), so 

68#: it is roman five -- suffix -- where 'V.' stays a middle 

69#: initial through that same carve-out; 'Jr。' simply reaches its 

70#: entry, and 'Dr。' its title. 

71#: decisions/cjk-full-stops carries the measurements. 

72FULL_STOPS = "..。。" 

73 

74 

75# The Arabic tatweel (kashida) stretches a word for looks and carries no 

76# letter, so deleting it changes no word's identity (#1151: `الدكتــور` 

77# is the title `الدكتور`). Token text is never rewritten. 

78_TATWEEL = "\u0640" 

79 

80 

81def _normalize(word: str) -> str: 

82 """Lowercase, NFC-compose, strip whitespace and EDGE full stops 

83 (FULL_STOPS, not the ASCII period alone -- #322). Interior periods 

84 survive on purpose: 'J.R.' must not collapse to 'jr' and hit the 

85 periodless vocabulary (v1 parity, pinned live 2026-07-17). 

86 Suffix-ACRONYM membership alone uses the period-free form (see 

87 _vocab.suffix_as_written), mirroring v1's is_suffix, which removed 

88 periods only for the acronym test. 

89 

90 lower(), NOT casefold(): casefold's caseless-matching folds mutate 

91 the stored vocabulary itself -- 'κος' becomes the misspelling 'κοσ' 

92 (final sigma flattened) and 'großfürst' becomes 'grossfürst' -- 

93 while lower() applies Unicode SpecialCasing contextually and keeps 

94 both as authored. This function is the single fold for storage AND 

95 match-time lookups, so matching stays symmetric either way; lower() 

96 is what v1's lc() used, preserving which cross-spellings match. 

97 

98 Tatweel (U+0640) is deleted, in a non-ASCII word only (#1151). 

99 

100 NFC, since #322, and only for a non-ASCII word (ASCII is already 

101 NFC, and this runs per token on the parse path -- 

102 decisions/parse-cost). NFD hangul decomposes to jamo and missed 

103 every vocabulary entry; composing at lookup AND at storage keeps 

104 the two symmetric, and no shipped entry changes under it (measured 

105 2026-09-09: fold every string member of every Lexicon.default() 

106 field through NFC and count the changes; zero of 1735). This is 

107 the same NFC composition script classification already applies 

108 (_vocab._normalized_for_script); token text is never rewritten. 

109 

110 Strips to a FIXED POINT. A single strip().strip(FULL_STOPS) leaves 

111 stops-around-whitespace half done ('. a .' -> ' a '), so a value 

112 that is re-normalized later -- on unpickle, or by a second add() -- 

113 would change under its owner. v1 never re-normalized, so this only 

114 matters now that storage and match-time share one fold.""" 

115 word = word.lower() 

116 if not word.isascii(): 

117 word = unicodedata.normalize("NFC", word) 

118 if _TATWEEL in word: 

119 word = word.replace(_TATWEEL, "") 

120 while True: 

121 stripped = word.strip().strip(FULL_STOPS) 

122 if stripped == word: 

123 return word 

124 word = stripped 

125 

126 

127# rules/P7: "A one-letter word written with its period is an 

128# initial and not a particle" 

129def _spells_an_initial(n: str, text: str) -> bool: 

130 """Whether `text` is ONE letter written with its period ('Ó.', 

131 'j.'), which makes it an initial whatever particle the letter also 

132 spells (#604). `n` is `_normalize(text)`, folded once by the caller. 

133 

134 Every particle test asks this AFTER its membership hit, so a word 

135 no particle set holds never pays the frame. It lives here rather 

136 than in _pipeline._vocab because _render's case repair, which 

137 re-reads the particle vocabulary over a parsed name, must ask it 

138 too and may not import the pipeline (the 2.x facade's initials and 

139 last-name split were a second such reader). 

140 

141 The ASCII period only, as `_vocab.is_initial` takes it: a CJK full 

142 stop marks no initial ('Ó。' is the particle). The other half of 

143 is_initial, the scripts that write no initials (#320), is NOT asked 

144 -- its script table is _policy's, which this module may not import 

145 -- so a caller's one-character particle in such a script, written 

146 with a period, is vetoed where is_initial would call it no initial 

147 (decisions/P7). No shipped particle is one character of those 

148 scripts.""" 

149 return len(n) == 1 and text[-1] == "." 

150 

151 

152def _fold_words(words: Iterable[str]) -> list[str]: 

153 """The words of a title run, folded for storage and lookup. 

154 

155 A multi-word title is matched as one key ('lt col'), so the fold has 

156 to run per word -- _normalize on the whole phrase would leave 

157 interior periods. 

158 

159 Words that fold away are DROPPED, not kept as empty. Keeping the 

160 gap makes the key non-idempotent -- 'lt .' would store 'lt ', which 

161 match time can never build: a run CAN carry a lone '.' (the 

162 conjunction merge puts one there), but the fold drops the empty 

163 word, so the key is 'lt' and the stored entry is inert. Storage 

164 re-runs this fold on unpickle and on every dataclasses.replace, so a 

165 value that changes under a second pass is one Lexicon later rejects as 

166 "not written by this version". _normalize converges for the same 

167 reason; so must anything built on top of it. 

168 

169 A LIST, so that _run_addresses_by_given's last-word arm can be the 

170 last word of this fold rather than a re-split of the joined key -- 

171 "the last word of the FOLDED key" is then structural, and the two 

172 arms read one fold between them. 

173 

174 map/filter rather than a comprehension: both are C calls where a 

175 comprehension is a Python frame on 3.11, and this runs on the parse 

176 path for every name carrying a title run (decisions/parse-cost). 

177 """ 

178 return list(filter(None, map(_normalize, words))) 

179 

180 

181def _title_key(words: Iterable[str]) -> str: 

182 """The given_name_titles lookup key for a run of title words: the 

183 folded words, space-joined. 

184 

185 Defined once because it is built at match time 

186 (_run_addresses_by_given, which is how post_rules' H1 and group's 

187 P5 licence both reach it) and wherever a caller's word is stored or 

188 looked up (_edit_words, which folds every word add() and remove() 

189 take, a phrase included, and readings_of), and a divergence between 

190 them fails silently: the entry simply stops matching.""" 

191 return " ".join(_fold_words(words)) 

192 

193 

194def _run_addresses_by_given(words: Iterable[str], 

195 vocabulary: Set[str]) -> bool: 

196 """Whether a run of title words addresses by the GIVEN name. 

197 

198 Two sites ask it and they must agree -- post_rules for H1, group 

199 for the P5 licence -- because a run read two ways is a rule 

200 contradicting itself (decisions/P5, the 2026-08-22 #369 entry). 

201 One predicate, so they cannot drift. 

202 

203 The WHOLE run's key, or that key's LAST word (#489): a run is 

204 written as several titles and addresses the way its final one does, 

205 so 'Her Majesty Queen' addresses by given name because 'queen' 

206 does. The two arms are asked of one key and neither is the other's 

207 fallback -- the `or` short-circuits but decides nothing, since for 

208 a one-word run the key IS its last word. Both arms read one 

209 _fold_words list, so the last word is the last word of the FOLDED 

210 key by construction rather than by agreement, and a run token that 

211 folds away cannot empty that arm: the conjunction merge can put a 

212 lone '.' in the run ('Sir and . John'), and the fold drops it. 

213 What the drop leaves as the last word can then be the CONJUNCTION 

214 -- 'Sir and . John' keys 'sir and' and reads family 'John', where 

215 'Sir and Dame John' keys 'sir and dame' and reads given. Harmless 

216 on the shipped vocabulary, which holds no entry ending in a 

217 connective, and a caller who stored one would be asking for it 

218 (measured 2026-09-09). 

219 

220 The whole-run arm is what keeps a caller's multi-word phrase entry 

221 working: 'lt col' is stored as one key and matched as one run. Over 

222 the SHIPPED vocabulary it is dead -- every shipped entry is a single 

223 word (asserted in test_lexicon.py), so it can only ever match a 

224 one-word run, which is a run the last-word arm reads the same way. 

225 

226 H2's unlisted abbreviations ride in the run. One can never match as 

227 the last-word key, being in no vocabulary by definition -- written 

228 as the inputs that produce those runs, 'Xyz. Sir John' keys 'xyz 

229 sir', matches on 'sir' and reads given 'John', while 'Sir Xyz. 

230 John' keys 'sir xyz', matches on neither arm and reads family 

231 'John'. It CAN sit inside a whole-run key that matches, because 

232 given_name_titles is deliberately not validated against titles: a 

233 caller who stores 'sir xyz' makes 'Sir Xyz. John' read given. 

234 

235 The vocabulary is passed in rather than read off a default: a 

236 caller's own Lexicon is the one that has to be consulted, and this 

237 module is where Lexicon is defined.""" 

238 folded = _fold_words(words) 

239 if not folded: 

240 # every word folded away, so there is no key and no last word: 

241 # the joined spelling built "" here, which matched nothing 

242 return False 

243 return " ".join(folded) in vocabulary or folded[-1] in vocabulary 

244 

245 

246def _reject_buffer(value: object, label: str, plural: str) -> None: 

247 """Binary sequences iterate to INTS, so every downstream entry check 

248 reports a byte value -- "must be strings, got 100" for b'dr', where 

249 100 is 'd'. That names neither the cause nor the fix. v1 shipped a 

250 decode hint for this (#238), and parse() carries one, so no config 

251 entry point should be the odd one out. 

252 """ 

253 if isinstance(value, (bytes, bytearray, memoryview)): 

254 raise TypeError( 

255 f"{label} must be an iterable of {plural}, not " 

256 f"{type(value).__name__} -- decode first, e.g. " 

257 f"raw.decode('utf-8')" 

258 ) 

259 

260 

261def _warn_at_caller(message: str) -> None: 

262 # A fixed stacklevel always lands on library internals: the call 

263 # depth differs per entry point (Lexicon(), add(), unpickle, 

264 # dataclasses.replace). Walk out of this module (and dataclasses' 

265 # replace frames) so the warning points at the caller's own line. 

266 level = 2 

267 frame: FrameType | None = sys._getframe(1) 

268 while frame is not None and frame.f_globals.get("__name__") in ( 

269 __name__, "dataclasses"): 

270 frame, level = frame.f_back, level + 1 

271 warnings.warn(message, UserWarning, stacklevel=level) 

272 

273 

274def _normset(entries: Iterable[str], field_name: str) -> frozenset[str]: 

275 # Reject a bare str before iterating: iterating "dr" would silently 

276 # yield the single characters {'d', 'r'} -- the set(str) footgun on 

277 # the primary customization surface. 

278 if isinstance(entries, str): 

279 raise TypeError( 

280 f"Lexicon.{field_name} must be an iterable of strings, " 

281 f"not a bare string" 

282 ) 

283 _reject_buffer(entries, f"Lexicon.{field_name}", "strings") 

284 # A Mapping would silently contribute only its keys; a dict here 

285 # almost always means the caller confused this field with 

286 # capitalization_exceptions. 

287 if isinstance(entries, Mapping): 

288 raise TypeError( 

289 f"Lexicon.{field_name} must be an iterable of strings, not a " 

290 f"mapping (only capitalization_exceptions holds key->value pairs)" 

291 ) 

292 try: 

293 items = tuple(entries) # materialize once; entries may be a generator 

294 except TypeError: 

295 raise TypeError( 

296 f"Lexicon.{field_name} must be an iterable of strings, " 

297 f"got {entries!r}" 

298 ) from None 

299 normalized = set() 

300 for w in items: 

301 if not isinstance(w, str): 

302 raise TypeError( 

303 f"Lexicon.{field_name} entries must be strings, got {w!r}" 

304 ) 

305 n = _normalize(w) 

306 # "." or "" is a data bug (stray split artifact, empty CSV 

307 # cell); dropping it silently would also let a data-module typo 

308 # vanish instead of failing CI. 

309 if not n: 

310 raise ValueError( 

311 f"Lexicon.{field_name} entry {w!r} normalizes to empty " 

312 f"(lowercase + strip full stops/whitespace leaves nothing)" 

313 ) 

314 # A list is matched one word at a time, so a multi-word entry 

315 # can never match -- the library itself shipped eight such dead 

316 # entries for years (repaired 2026-07-26). Warn, never raise: an 

317 # inert entry produces nothing, while a raise costs a working 

318 # configuration (AGENTS.md's gotcha on given-name title runs, 

319 # the precedent). split() covers all Unicode whitespace. 

320 if n != "".join(n.split()): 

321 _warn_at_caller( 

322 f"Lexicon.{field_name} entries are matched one word at " 

323 f"a time; multi-word entry {w!r} can never match. " 

324 f"Split it into separate entries") 

325 normalized.add(n) 

326 return frozenset(normalized) 

327 

328 

329def _alnum(text: str) -> str: 

330 return "".join(c for c in text if c.isalnum()) 

331 

332 

333def _offered_mask(normalized_key: str) -> str: 

334 """The working value a mismatched-mask error offers: the 

335 key upper-cased, or the key unchanged -- an identity mask, always 

336 legal -- where .upper() changes the letter count ('straße' -> 

337 'STRASSE') and would fail the check if pasted back in.""" 

338 upper = normalized_key.upper() 

339 return upper if _alnum(_normalize(upper)) == _alnum( 

340 normalized_key) else normalized_key 

341 

342 

343class _MaskValueError(ValueError): 

344 """_normpairs' error for a value that does not spell its key. 

345 Built in the UnicodeDecodeError idiom -- structure in, message 

346 out: `key`, `normalized_key` and `value` are the offending inputs; 

347 `offered` (the fix quoted in the message below) is computed from 

348 them, not carried separately. 

349 

350 __reduce__ is explicit because the inherited one re-calls 

351 __init__ with self.args, which holds the message alone, not the 

352 three fields __init__ needs -- and a ProcessPoolExecutor pickles a 

353 worker's exception to deliver it, surfacing a construction failure 

354 as BrokenProcessPool. Passing self.__dict__ as reduce's third 

355 element (restored via a plain __dict__.update, there being no 

356 __setstate__ here) carries along anything added after construction 

357 -- e.g. add_note() -- that reconstructing from the three fields 

358 alone would not. (An exception, so _types.py's frozen-dataclass 

359 pickle guards do not apply.)""" 

360 

361 def __init__(self, key: str, normalized_key: str, value: str) -> None: 

362 self.key = key 

363 self.normalized_key = normalized_key 

364 self.value = value 

365 self.offered = _offered_mask(normalized_key) 

366 super().__init__( 

367 f"capitalization_exceptions value {value!r} for key {key!r} " 

368 f"does not spell the key's letters and digits: a value " 

369 f"is a case mask, the key's own letters and digits " 

370 f"recased -- e.g. " 

371 f"capitalization_exceptions=(({normalized_key!r}, " 

372 f"{self.offered!r}),)") 

373 

374 def __reduce__( 

375 self, 

376 ) -> tuple[type[_MaskValueError], tuple[str, str, str], dict[str, object]]: 

377 return (type(self), (self.key, self.normalized_key, self.value), 

378 self.__dict__) 

379 

380 

381def _normpairs( 

382 raw: Mapping[str, str] | Iterable[tuple[str, str]], 

383) -> tuple[tuple[str, str], ...]: 

384 """Canonicalize capitalization_exceptions input: _normset's sibling 

385 for the one pair-valued field. Dedupes on the NORMALIZED key so the 

386 tuple and the derived map always agree ("PHD" and "phd" collide 

387 after normalization; "Ph.D." does not, the fold keeping interior 

388 periods, so it is a key of its own, "ph.d"); last occurrence wins, 

389 matching dict semantics and the right-bias rule used elsewhere.""" 

390 if isinstance(raw, str): 

391 raise TypeError( 

392 "capitalization_exceptions must be a mapping or an " 

393 "iterable of (key, value) pairs, not a bare string" 

394 ) 

395 _reject_buffer(raw, "capitalization_exceptions", "(key, value) pairs") 

396 pairs = raw.items() if isinstance(raw, Mapping) else raw 

397 try: 

398 pairs = iter(pairs) 

399 except TypeError: 

400 raise TypeError( 

401 "capitalization_exceptions must be a mapping or an " 

402 f"iterable of (key, value) pairs, got {raw!r}" 

403 ) from None 

404 deduped: dict[str, str] = {} 

405 for entry in pairs: 

406 # A 2-char str entry would unpack "ab" into ("a", "b") 

407 # silently, so reject str outright; other mis-shapes would 

408 # otherwise surface as bare unpack errors. 

409 if isinstance(entry, str): 

410 raise TypeError( 

411 f"capitalization_exceptions entries must be " 

412 f"(key, value) pairs, got {entry!r}" 

413 ) 

414 try: 

415 k, v = entry 

416 except (TypeError, ValueError): 

417 raise TypeError( 

418 f"capitalization_exceptions entries must be " 

419 f"(key, value) pairs, got {entry!r}" 

420 ) from None 

421 if not isinstance(k, str) or not isinstance(v, str): 

422 raise TypeError( 

423 f"capitalization_exceptions entries must be " 

424 f"str -> str, got {k!r}: {v!r}" 

425 ) 

426 normalized_key = _normalize(k) 

427 if not normalized_key: 

428 raise ValueError( 

429 f"capitalization_exceptions key {k!r} normalizes to " 

430 f"empty (lowercase + strip full stops/whitespace leaves " 

431 f"nothing)" 

432 ) 

433 # Stored NFC-composed, case kept (unicodedata, not _normalize): 

434 # one stored spelling per value, whichever form the caller 

435 # wrote. _apply_mask reads both forms alike since #542 

436 # (_render._beside looks past combining marks); before it, a 

437 # decomposed letter read as a base letter split off beside a 

438 # non-alpha combining mark. The mask check below 

439 # passes either spelling, _normalize composing both of its 

440 # sides. `written` keeps the caller's own spelling -- composed 

441 # or not -- for the mismatch error below: a decomposed value 

442 # (e.g. 'e' + a combining acute) and its NFC form print 

443 # identically to a reader's eye but are different str objects, 

444 # so echoing the composed spelling back would show the caller 

445 # a value they did not write. 

446 written = v 

447 v = unicodedata.normalize("NFC", v) 

448 # capitalized() looks words up one at a time (the _WORD regex 

449 # never yields spaces), so a multi-word key is unreachable -- 

450 # checked FIRST, ahead of the mask check below, because an 

451 # invariant guards harm and a value that can never be read 

452 # does no harm no matter what it says (AGENTS.md: "a check 

453 # guards harm, not no-ops"). Raising the mask error for such a 

454 # key -- as a naive mask-first order does -- would refuse a 

455 # config that was always going to be a no-op. 

456 # interior whitespace test; split() covers all Unicode whitespace 

457 if normalized_key != "".join(normalized_key.split()): 

458 _warn_at_caller( 

459 f"capitalization_exceptions keys are matched one word " 

460 f"at a time; multi-word key {k!r} can never match. " 

461 f"Split it into per-word entries") 

462 deduped[normalized_key] = v 

463 continue 

464 # A value is a case MASK (#459): the key's own letters and 

465 # digits recased, compared through the key's own fold. Its 

466 # punctuation marks which letters are joined into one run 

467 # (_render._apply_mask reads that) and is never written into 

468 # the word. A raise, not a warning: a mismatched value used to 

469 # be SUBSTITUTED for the word, and rules/R4: "Repair 

470 # changes case and nothing else", so there is no reading of 

471 # one that repair can honor. 

472 if _alnum(_normalize(v)) != _alnum(normalized_key): 

473 # key is the RAW key, not normalized_key, and `written`, 

474 # not `v`: the message names the entry as the caller wrote 

475 # it, not as the fold or NFC composed it. 

476 raise _MaskValueError(k, normalized_key, written) 

477 deduped[normalized_key] = v 

478 return tuple(sorted(deduped.items())) 

479 

480 

481#: The two lists outside the per-word model (decisions/vocabulary-model's 

482#: Standing line on name readings): `Lexicon` fields, changed through 

483#: ``|`` with a fragment or ``dataclasses.replace``, and copied into 

484#: `_LexiconSets` beside the compiled sets. 

485_LIST_FIELDS = ("surnames", "given_names") 

486 

487#: The 20 sets a compile of readings gives, under the names the 2.x 

488#: `Lexicon` fields had: internal since 3.0 (#754, 4i-b), read through 

489#: `Lexicon._sets`, and the names an old spelling is refused by. 

490_SET_FIELDS = tuple(f for f in _VOCAB_FIELDS if f not in _LIST_FIELDS) 

491 

492#: What those sets do not record: the kinds the views read (rules/R8, 

493#: rules/R9), and the titles written short that rules/H5's trailing 

494#: chain and rules/H9's article partner read where one may also be a 

495#: given name (#999), which is no 

496#: kind; the name is the tuple's first use. 

497#: `_LexiconSets` attributes after the `_VOCAB_FIELDS` ones, each a 

498#: compiled constant of its own. 

499_KIND_FIELDS = ("generation_words", "credential_words", "honorific_words", 

500 "patronymic_particles", "title_abbreviations", 

501 "title_case_name_words", "shared_names", 

502 "particle_given_names", "saint_particles", 

503 "capitalized_particles", "capital_opening_particles", 

504 "dotted_titles", "glued_titles", "bound_phrases") 

505 

506@dataclass(frozen=True, slots=True, eq=False, repr=False) 

507class _LexiconSets: 

508 """The vocabulary sets a parse reads: the pipeline, `_render`, 

509 `_parser` and the tools read `lexicon._sets.titles` (#754, 4i-a; 

510 tests/v2/test_layering.py holds it), the 2.x public fields having 

511 become internal in 4i-b. One attribute per `_VOCAB_FIELDS` entry, in 

512 that order, then one per `_KIND_FIELDS` entry, each a frozenset, so 

513 a membership test stays a C-level attribute read and costs no frame. 

514 

515 Built once per `Lexicon`, at construction: the compiled sets from 

516 its readings by `compile_readings`, or for `Lexicon.default()` from 

517 nameparser/config/_compiled.py, so the reading index stays unloaded 

518 on `parse("John Smith")`; the two lists from its fields. Never 

519 compared, hashed or pickled: a `Lexicon`'s equality and pickle are 

520 its fields', and a load or a `dataclasses.replace` builds this 

521 again. 

522 

523 Not a mapping and no repr of its own: it holds whole word lists, 

524 and a repr of them would be unbounded.""" 

525 

526 titles: frozenset[str] 

527 given_name_titles: frozenset[str] 

528 suffix_acronyms: frozenset[str] 

529 suffix_words: frozenset[str] 

530 suffix_acronyms_ambiguous: frozenset[str] 

531 particles: frozenset[str] 

532 particles_ambiguous: frozenset[str] 

533 conjunctions: frozenset[str] 

534 conjunctions_ambiguous: frozenset[str] 

535 bound_given_names: frozenset[str] 

536 given_name_abbreviations: frozenset[str] 

537 maiden_markers: frozenset[str] 

538 surnames: frozenset[str] 

539 honorific_tails: frozenset[str] 

540 after_given_titles: frozenset[str] 

541 suffix_words_ambiguous: frozenset[str] 

542 agnomens: frozenset[str] 

543 titles_ambiguous: frozenset[str] 

544 title_modifiers: frozenset[str] 

545 title_styles: frozenset[str] 

546 title_phrases: frozenset[str] 

547 given_names: frozenset[str] 

548 #: the suffix words whose suffix reading is a generation (`jr`, 

549 #: `iii`), a credential written as a word (`kc`), or an honorific 

550 #: (`さん`, `先生`), and the particles read as patronymics (`bin`): 

551 #: the kinds rules/R8's and rules/R9's views read, which the sets 

552 #: above do not record 

553 generation_words: frozenset[str] 

554 credential_words: frozenset[str] 

555 honorific_words: frozenset[str] 

556 patronymic_particles: frozenset[str] 

557 #: the words whose title reading is written `abbreviation=True` 

558 #: (`maj`); rules/H5's trailing chain reads one only beside 

559 #: `titles_ambiguous`, through classify's TRAILING_TITLE_TAG (#999) 

560 title_abbreviations: frozenset[str] 

561 #: the suffix words written `title_case_is_name=True` (`fils`, 

562 #: `kc`), whose writing leans where a name reading gates them: 

563 #: lower case is the suffix, Title case the name (rules/S6, #1039) 

564 title_case_name_words: frozenset[str] 

565 #: the names written `shared=True` (Sikh `kaur`, `singh`), which a 

566 #: community gives every member: rules/O6 and O7 neither take nor 

567 #: complete one, and P10 completes none (#704) 

568 shared_names: frozenset[str] 

569 #: the given names written `after_particle=True` (`carmen`), the 

570 #: second of a compound given name written behind a particle 

571 #: (`María del Carmen`): rules/P10 reads one as a middle name behind 

572 #: a one-word given name, and O6 leaves it there (#704) 

573 particle_given_names: frozenset[str] 

574 #: the particles of the `saint` kind (`san`, `santa`, `st`, `ste`): no given 

575 #: name phrase opens with one, `Santa María` being a surname (#704) 

576 saint_particles: frozenset[str] 

577 #: the particles of the kinds written with a capital, `saint` and 

578 #: `rank` (`st`, `san`, `freiherr`, `graf`): rules/R4's case repair 

579 #: writes one as a name word, not in lowercase (#1069) 

580 capitalized_particles: frozenset[str] 

581 #: the particles written `capital_opening=True` (`ter`, `der`): 

582 #: rules/R12's case repair writes one with a capital where no 

583 #: particle stands before it in the family name, nor, where it opens 

584 #: the family name, written right before it in any role (`Levon Ter 

585 #: Petrosyan`, but `Van der Walt`) 

586 capital_opening_particles: frozenset[str] 

587 #: the titles written `dotted=True` (`us`, `uk`, `pd`): rules/H10 

588 #: reads a run of single letters joined by full stops that spells 

589 #: one, at the head of a name (the given part's title run, or H6's 

590 #: before a family comma), as that title where a title the run keeps 

591 #: stands behind it (`U.S. District Judge`, #490) 

592 dotted_titles: frozenset[str] 

593 #: the titles written `attached="before"` (Thai `นาย`, `นางสาว`, 

594 #: `อาจารย์`): rules/W6 splits one off the start of the name's first 

595 #: word it is glued to, the mirror of `honorific_tails` (#317) 

596 glued_titles: frozenset[str] 

597 #: the two-word given names a bound word's `completed_by` lists 

598 #: ('عبد الله'), which rules/P12 joins past the first name word (#694) 

599 bound_phrases: frozenset[str] 

600 

601 

602#: Lexicon field -> the compiled constant it is read from, for every 

603#: set the vocabulary compiles (nameparser/_reading_index.py's 

604#: Compiled); `particles_ambiguous` is derived (_vocab_fields below). 

605#: tests/v2/test_parser.py holds that these, read from the compile of 

606#: no choice, are Lexicon.default()'s. 

607_COMPILED_FIELDS = { 

608 "titles": "TITLES", 

609 "given_name_titles": "GIVEN_NAME_TITLES", 

610 "suffix_acronyms": "SUFFIX_ACRONYMS", 

611 "suffix_words": "SUFFIX_WORDS", 

612 "suffix_acronyms_ambiguous": "SUFFIX_ACRONYMS_AMBIGUOUS", 

613 "particles": "PARTICLES", 

614 "conjunctions": "CONJUNCTIONS", 

615 "conjunctions_ambiguous": "CONJUNCTIONS_AMBIGUOUS", 

616 "bound_given_names": "BOUND_GIVEN_NAMES", 

617 "given_name_abbreviations": "GIVEN_NAME_ABBREVIATIONS", 

618 "maiden_markers": "MAIDEN_MARKERS", 

619 "honorific_tails": "GLUED_HONORIFICS", 

620 "after_given_titles": "AFTER_GIVEN_TITLES", 

621 "suffix_words_ambiguous": "SUFFIX_WORDS_AMBIGUOUS", 

622 "agnomens": "AGNOMENS", 

623 "titles_ambiguous": "TITLES_AMBIGUOUS", 

624 "title_modifiers": "TITLE_MODIFIERS", 

625 "title_styles": "TITLE_STYLES", 

626 "title_phrases": "TITLE_PHRASES", 

627 "generation_words": "GENERATION_WORDS", 

628 "credential_words": "CREDENTIAL_WORDS", 

629 "honorific_words": "HONORIFIC_WORDS", 

630 "patronymic_particles": "PATRONYMIC_PARTICLES", 

631 "title_abbreviations": "TITLE_ABBREVIATIONS", 

632 "title_case_name_words": "TITLE_CASE_NAME_WORDS", 

633 "shared_names": "SHARED_NAMES", 

634 "particle_given_names": "PARTICLE_GIVEN_NAMES", 

635 "saint_particles": "SAINT_PARTICLES", 

636 "capitalized_particles": "CAPITALIZED_PARTICLES", 

637 "capital_opening_particles": "CAPITAL_OPENING_PARTICLES", 

638 "dotted_titles": "DOTTED_TITLES", 

639 "glued_titles": "GLUED_TITLES", 

640 "bound_phrases": "BOUND_PHRASES", 

641} 

642 

643 

644#: The groups `SAME_AS` is keyed by, one per kind of role a name match 

645#: compares (`_reading_index.SAME_AS_GROUPS` maps a reading's kind to 

646#: one; tests/v2/test_lexicon.py holds the two to one list). 

647_SAME_AS_GROUPS = frozenset({"given", "family", "title", "suffix"}) 

648 

649#: A group -> word -> the words it is a listed spelling of. 

650SameAs = Mapping[str, Mapping[str, frozenset[str]]] 

651 

652 

653def _same_as_of(compiled: Mapping[str, Any]) -> SameAs: 

654 """The compile's `SAME_AS` as the lexicon keeps it: per group, each 

655 word's full forms normalized, as a caller's `same_as` is written 

656 free-form.""" 

657 return MappingProxyType({ 

658 group: MappingProxyType({ 

659 w: frozenset(_normalize(h) for h in heads) 

660 for w, heads in words.items()}) 

661 for group, words in compiled["SAME_AS"].items()}) 

662 

663 

664def _vocab_fields(compiled: Mapping[str, Any]) -> dict[str, frozenset[str]]: 

665 """The sets a compile of the vocabulary gives, by `_LexiconSets` 

666 name (the constants of nameparser/config/_compiled.py, or a compile 

667 of readings), the two lists aside.""" 

668 out = {name: compiled[key] for name, key in _COMPILED_FIELDS.items()} 

669 # FLIPPED from v1: v1 marked the never-given subset; the sets mark 

670 # the may-be-given one 

671 out["particles_ambiguous"] = (compiled["PARTICLES"] 

672 - compiled["NON_GIVEN_NAME_PARTICLES"]) 

673 return out 

674 

675 

676#: A group -> the words of it with a listed spelling that are a name of 

677#: their own too (`SAME_AS_NAMED`). 

678SameAsNamed = Mapping[str, frozenset[str]] 

679 

680 

681# -- the name match's fold, which the familiar-form index shares -------- 

682 

683#: Full stops and apostrophes: the typed one, the typographic U+2019, 

684#: the modifier letter U+02BC, and Uzbek Latin's other spellings of 

685#: the `ʻ` in `oʻ` and `gʻ` (U+02BB, U+2018, the backtick), so 

686#: `O’Brien` folds to `obrien` and `Gʻofurov` to `gofurov` 

687#: (decisions/name-matching, 2026-11-09). And the Arabic tatweel U+0640, 

688#: which `_normalize` deletes too (#1151), so a name's two spellings that 

689#: differ by one fold alike where the vocabulary and the match meet. 

690_DROPPED = str.maketrans("", "", ".'`\u2019\u02bc\u02bb\u2018\u0640") 

691#: The scripts whose combining marks `_fold` drops as accents. 

692_ACCENTED_SCRIPTS = ("LATIN ", "GREEK ", "CYRILLIC ") 

693 

694def _fold(text: str) -> str: 

695 """The text with case, accents, full stops and apostrophes folded 

696 away: NFC, `.` and the apostrophes and their stand-ins (`_DROPPED`) removed, 

697 NFD, casefolded, then NFC again, so the folded text, and every key 

698 cut from it, is precomposed (a Hangul syllable, never a conjoining 

699 jamo). Hyphens and spaces are kept. A combining mark (Mn) is dropped 

700 only where the letter it attaches to is Latin, Greek or Cyrillic, 

701 where it is an accent; in another script (a Devanagari virama, 

702 anusvara or vowel sign) it is spelling and is kept. A letter counts 

703 as in those scripts where it or its casefolded form is, so a letter 

704 that casefolds into one (the micro sign U+00B5 into Greek mu, U+2183 

705 into Latin) loses its mark on the first fold, which keeps the fold 

706 idempotent; so does removing the full stops and apostrophes before 

707 the marks are read, a mark after one then attaching to the letter 

708 before it, and again after casefolding, which can write one (U+0149 

709 `ʼn` casefolds to `ʼn`). The iota subscript U+0345 is a mark like any 

710 other, so `ᾳ` folds to `α`.""" 

711 if text.isascii(): 

712 # no accent, no composition, and no apostrophe but the typed 

713 # one and the backtick 

714 return text.casefold().translate(_DROPPED) 

715 decomposed = unicodedata.normalize( 

716 "NFD", unicodedata.normalize("NFC", text).translate(_DROPPED)) 

717 kept = [] 

718 accented = False 

719 for c in decomposed: 

720 if unicodedata.category(c) == "Mn": 

721 if accented: 

722 continue 

723 else: 

724 accented = (unicodedata.name(c, "").startswith(_ACCENTED_SCRIPTS) 

725 or unicodedata.name(c.casefold()[:1] or c, "") 

726 .startswith(_ACCENTED_SCRIPTS)) 

727 kept.append(c) 

728 # again after casefolding, which can write an apostrophe (U+0149) 

729 return unicodedata.normalize( 

730 "NFC", "".join(kept).casefold().translate(_DROPPED)) 

731 

732 

733#: A given name -> every name it is paired with as a familiar form, in 

734#: either direction (`FAMILIAR`, read both ways). 

735Familiar = Mapping[str, frozenset[str]] 

736 

737 

738def _familiar_of(compiled: Mapping[str, Any]) -> Familiar: 

739 """The compile's `FAMILIAR`, a familiar form -> the longer names its 

740 `short_for` lists, read both ways: each word with every name it is 

741 paired with directly, as the shorter name or the longer. The 

742 sources do not always write the shorter name first (nam_dict's 

743 `Maria` / `Masha`, the nicknames list's `barbara` / `babs`), so the 

744 match reads a pair whichever way it was written, and never chains 

745 two pairs (decisions/name-matching, 2026-11-08 

746 FAMILIAR FORMS). Each name is indexed under its fold too (`_fold`, 

747 the match's own), so a pair written accented (`józsi`, `józsef`) 

748 is found from either name written without its accents.""" 

749 paired: dict[str, set[str]] = {} 

750 for short, longs in compiled["FAMILIAR"].items(): 

751 for long in longs: 

752 for key in {short, _fold(short)}: 

753 paired.setdefault(key, set()).add(long) 

754 for key in {long, _fold(long)}: 

755 paired.setdefault(key, set()).add(short) 

756 return MappingProxyType({w: frozenset(ns) for w, ns in paired.items()}) 

757 

758 

759#: What a lexicon's readings compile to: the vocabulary sets by 

760#: `_LexiconSets` name, the listed-spelling map, the words in it that 

761#: are a name of their own as well, and the familiar forms. 

762_Compiled = tuple[Mapping[str, frozenset[str]], SameAs, SameAsNamed, 

763 Familiar] 

764 

765 

766def _compiled_of(compiled: Mapping[str, Any]) -> _Compiled: 

767 return (MappingProxyType(_vocab_fields(compiled)), _same_as_of(compiled), 

768 MappingProxyType(dict(compiled["SAME_AS_NAMED"])), 

769 _familiar_of(compiled)) 

770 

771 

772@functools.cache 

773def _default_compiled() -> _Compiled: 

774 # the compiled data module is the default's one source, so the 

775 # default never loads the reading index (decisions/vocabulary- 

776 # model, 2026-10-29 3a; its 2026-11-02 #754 entry, item 4) 

777 import nameparser.config._compiled as compiled 

778 return _compiled_of(vars(compiled)) 

779 

780 

781_NO_SETS: Mapping[str, frozenset[str]] = MappingProxyType( 

782 {name: frozenset() for name in _COMPILED_FIELDS} | { 

783 "particles_ambiguous": frozenset()}) 

784#: What the empty base compiles to: no sets and no listed spellings. 

785_NOTHING_COMPILED: _Compiled = (_NO_SETS, MappingProxyType( 

786 {g: MappingProxyType({}) for g in sorted(_SAME_AS_GROUPS)}), 

787 MappingProxyType({g: frozenset() for g in sorted(_SAME_AS_GROUPS)}), 

788 MappingProxyType({})) 

789 

790 

791# -- readings ------------------------------------------------------------ 

792 

793#: A reading's properties as the log and the repr compare them: sorted 

794#: (key, value) pairs, a list value a sorted tuple. 

795_Props = tuple[tuple[str, Any], ...] 

796 

797 

798class _Edit(NamedTuple): 

799 """One `add` or `remove`, stored frozen as written (decisions/ 

800 vocabulary-model, 2026-11-02 #754, item 3): its words normalized, 

801 its kind (None on a `remove` of every reading), its properties 

802 sorted pairs, a property given its unwritten value held as None, 

803 which removes it. Compared and hashed as a tuple.""" 

804 

805 op: str 

806 words: tuple[str, ...] 

807 kind: str | None 

808 props: _Props 

809 

810 

811def _index_module() -> ModuleType: 

812 # the reading index's reader, imported at call time: tools/vocab/ 

813 # build.py loads this module by path, before the index it rewrites 

814 # can be trusted to import (root AGENTS.md's layering bullet) 

815 import nameparser._reading_index as ri 

816 return ri 

817 

818 

819def _clean(props: Mapping[str, Any]) -> _Props: 

820 """A reading's properties as a caller states them: `default` (a 

821 caller's reading is on) and `cased` (a case mask, configured through 

822 `capitalization_exceptions`) left out.""" 

823 return tuple(sorted((k, v) for k, v in props.items() 

824 if k not in ("default", "cased"))) 

825 

826 

827def _unwritten(kind: str, prop: str) -> object: 

828 """The value `prop` takes on a `kind` reading that does not write 

829 it, read through _reading_index.ReadingProperties: `acronym` is 

830 true on a credential, `addresses` the family name.""" 

831 bare = _index_module().IndexReading("", "", kind, MappingProxyType({})) 

832 if prop == "acronym": 

833 return bare.acronym 

834 if prop == "addresses": 

835 return bare.addresses 

836 return bare.flag(prop) 

837 

838 

839#: The properties whose value is a list of words, each with what its 

840#: entries are and an example, for the messages refusing a value. 

841_LIST_PROPS: Mapping[str, str] = MappingProxyType({ 

842 "attaches": "title kinds or title words, e.g. attaches=['military']", 

843 "completed_by": "single words, e.g. completed_by=['الله']", 

844 "short_for": "given names, e.g. short_for=['maxwell']", 

845}) 

846 

847 

848def _prop_value(kind: str, prop: str, value: object) -> object: 

849 """`value` canonical for the log, None where it is the unwritten 

850 one (which removes the property).""" 

851 if value is None or (isinstance(value, (bool, str)) 

852 and value == _unwritten(kind, prop)): 

853 return None 

854 if prop in ("abbreviation", "acronym", "after_given", "may_be_initial", 

855 "title_case_is_name", "shared", "after_particle", 

856 "capital_opening", "dotted"): 

857 if not isinstance(value, bool): 

858 raise TypeError(f"{prop} must be True or False, got {value!r}") 

859 return value 

860 if prop == "addresses": 

861 if value != "given": 

862 raise ValueError(f"addresses must be 'given' or 'family', " 

863 f"got {value!r}") 

864 return value 

865 if prop == "attached": 

866 # a suffix glued after the name (rules/W2) or a title glued in 

867 # front of it (rules/W6), the side its category reads 

868 side = ("before" if _index_module().CATEGORY.get(kind) == "title" 

869 else "after") 

870 if value != side: 

871 raise ValueError(f"attached on a {kind!r} reading must be " 

872 f"{side!r} (a {'title peeled off the start' if side == 'before' else 'tail peeled off the end'} " 

873 f"of a name token) or None, got {value!r}") 

874 return value 

875 if prop in _LIST_PROPS: 

876 what = _LIST_PROPS[prop] 

877 if isinstance(value, (str, bytes)) or isinstance(value, Mapping): 

878 raise TypeError(f"{prop} must be an iterable of {what}, " 

879 f"got {value!r}") 

880 try: 

881 items: tuple[object, ...] = tuple(cast("Iterable[object]", value)) 

882 except TypeError: 

883 raise TypeError(f"{prop} must be an iterable of {what}, " 

884 f"got {value!r}") from None 

885 words = [x for x in items if isinstance(x, str)] 

886 if len(words) != len(items): 

887 raise TypeError(f"{prop} entries must be strings, got " 

888 f"{value!r}") 

889 if prop == "short_for" and ( 

890 not words or any(not _normalize(x) for x in words)): 

891 # the build's check 3: each a given name, none empty 

892 raise ValueError( 

893 f"short_for must be a non-empty list of the given names " 

894 f"the word stands for, none normalizing to nothing, got " 

895 f"{value!r}; write e.g. short_for=['maxwell']") 

896 folded = tuple(sorted({_normalize(x) for x in words} - {""})) 

897 if prop == "completed_by" and any(" " in w for w in folded): 

898 # rules/P12 joins a bound word to the ONE word after it 

899 raise ValueError(f"completed_by entries must be single words, " 

900 f"got {value!r}; list each completing word " 

901 f"on its own") 

902 if prop == "short_for" and any(" " in w for w in folded): 

903 # the match compares a given name word by word 

904 raise ValueError(f"short_for entries must be single given " 

905 f"names, got {value!r}; list each on its own") 

906 return folded or None 

907 # same_as: the word this one is a listed spelling of, a given name 

908 # written short's full name (#789) or a title's or suffix's full form 

909 if not isinstance(value, str) or not value.strip(): 

910 raise TypeError(f"same_as must be the word this one is a spelling " 

911 f"of, a non-empty string, got {value!r}") 

912 return value 

913 

914 

915def _check_kind(kind: object, op: str) -> str | None: 

916 """`kind` checked for `op`: a kind, or on `remove` and `words` a 

917 category too; None (every reading) only on `remove`.""" 

918 ri = _index_module() 

919 if kind is None and op == "remove": 

920 return None 

921 if not isinstance(kind, str): 

922 raise TypeError(f"{op}() takes kind as a string, a vocabulary " 

923 f"kind such as 'title' or 'credential', got " 

924 f"{kind!r}") 

925 if kind in ri.CATEGORY: 

926 return kind 

927 categories = set(ri.CATEGORY.values()) 

928 if kind in categories: 

929 if op in ("remove", "words"): 

930 return kind 

931 raise ValueError( 

932 f"{kind!r} is a category with no kind of its name; add() " 

933 f"takes one of its kinds: " + ", ".join( 

934 k for k, c in ri.CATEGORY.items() if c == kind)) 

935 raise ValueError( 

936 f"unknown reading kind {kind!r}; the kinds are " 

937 + ", ".join(ri.CATEGORY) 

938 + (f" (or a category, on {op}: " 

939 + ", ".join(sorted(categories)) + ")" 

940 if op in ("remove", "words") else "")) 

941 

942 

943def _edit_words(words: tuple[object, ...], op: str, 

944 kind: str | None) -> tuple[str, ...]: 

945 if not words: 

946 if op == "remove" and kind is not None: 

947 # every word's readings of `kind`: a remove of a whole kind, 

948 # which `remove` writes only with every=True, so that an 

949 # empty `remove(*words, kind=...)` never reaches it 

950 return () 

951 raise TypeError( 

952 f"{op}() takes at least one word, e.g. {op}('dean', kind=" 

953 f"'title')" + (", or every=True with a kind to take out every " 

954 "word's readings of it, e.g. remove(kind=" 

955 "'title', every=True)" if op == "remove" else "")) 

956 out = [] 

957 for w in words: 

958 _reject_buffer(w, f"{op}() words", "strings") 

959 if not isinstance(w, str): 

960 raise TypeError( 

961 f"{op}() takes each word as its own argument, a string, " 

962 f"got {w!r}; unpack a collection: {op}(*words, kind=...)") 

963 n = _title_key(w.split()) 

964 if not n: 

965 raise ValueError(f"{op}() word {w!r} normalizes to empty " 

966 f"(lowercase + strip full stops/whitespace " 

967 f"leaves nothing)") 

968 out.append(n) 

969 return tuple(out) 

970 

971 

972def _edit_props(kind: str, props: Mapping[str, object]) -> _Props: 

973 ri = _index_module() 

974 allowed = ri.indexed(kind) - {"default", "cased"} 

975 if kind == "family": 

976 # a name category's property that only a given name takes: the 

977 # raise below names it, so the generic message leaves it out 

978 allowed -= {"after_particle"} 

979 out: dict[str, object] = {} 

980 for prop, value in props.items(): 

981 if prop == "default": 

982 raise ValueError( 

983 "add() takes no default: a caller's reading is on, which " 

984 "is the caller's choice; take one out with remove(word, " 

985 "kind=...)") 

986 if prop == "cased": 

987 raise ValueError( 

988 "add() takes no cased: a case mask is configured through " 

989 "capitalization_exceptions, e.g. dataclasses.replace(" 

990 "lexicon, capitalization_exceptions=(*lexicon." 

991 "capitalization_exceptions, ('phd', 'PhD')))") 

992 if prop == "after_particle" and kind == "family": 

993 raise ValueError( 

994 "after_particle names a given name written behind a " 

995 "particle (rules/O7), so only a given reading takes it: " 

996 "write add(word, kind='given', after_particle=True)") 

997 if prop not in allowed: 

998 raise ValueError( 

999 f"a {kind} reading takes " 

1000 + (", ".join(sorted(allowed)) or "no properties") 

1001 + f"; got {prop!r}") 

1002 out[prop] = _prop_value(kind, prop, value) 

1003 return tuple(sorted(out.items())) 

1004 

1005 

1006def _make_edit(op: object, words: object, kind: object, 

1007 props: object) -> _Edit: 

1008 """An edit checked and canonical: what `add` and `remove` store and 

1009 what a constructor's `_edits` must already be.""" 

1010 if op not in ("add", "remove"): 

1011 raise ValueError(f"an edit is 'add' or 'remove', got {op!r}") 

1012 if not isinstance(words, tuple): 

1013 raise TypeError(f"an edit's words are a tuple, got {words!r}") 

1014 k = _check_kind(kind, op) 

1015 ws = _edit_words(words, op, k) 

1016 if op == "remove": 

1017 if props: 

1018 raise ValueError(f"remove() takes no properties, got {props!r}") 

1019 return _Edit(op, ws, k, ()) 

1020 assert k is not None 

1021 if not isinstance(props, (tuple, Mapping)): 

1022 raise TypeError(f"an edit's properties are pairs, got {props!r}") 

1023 p = _edit_props(k, dict(props)) 

1024 ri = _index_module() 

1025 category = ri.CATEGORY[k] 

1026 if category == "familiar": 

1027 # the build's check 3: a familiar reading lists the longer names 

1028 longs = dict(p).get("short_for") 

1029 if not longs: 

1030 raise ValueError( 

1031 "a familiar reading needs short_for, a non-empty list of " 

1032 "the given names the word stands for, e.g. add('max', " 

1033 "kind='familiar', short_for=['maxwell'])") 

1034 for w in ws: 

1035 if w in longs: 

1036 raise ValueError( 

1037 f"short_for names the word itself, {w!r}; it lists " 

1038 f"the other given names {w!r} stands for") 

1039 for w in ws: 

1040 # the build's check 8: a phrase is matched whole only as a birth 

1041 # marker or a run of titles addressing the given name 

1042 if " " in w and not (k == "birth_marker" or ( 

1043 category == "title" and ("addresses", "given") in p)): 

1044 raise ValueError( 

1045 f"{w!r} is a phrase, which can never match as a {k}: only " 

1046 f"a birth_marker and a title with addresses='given' are " 

1047 f"matched as a whole run; add its words one by one") 

1048 # the build refuses attached on a title phrase: the glued-title 

1049 # peel splits one word (rules/W6) 

1050 if " " in w and category == "title" and ("attached", "before") in p: 

1051 raise ValueError( 

1052 f"{w!r} is a phrase; attached='before' marks a one-word " 

1053 f"title that the glued-title peel splits off a word") 

1054 # the build refuses may_be_initial on anything but a cased single 

1055 # letter: no other word reads as an initial (rules/P8) 

1056 if ("may_be_initial", True) in p and not ( 

1057 len(w) == 1 and w.upper() != w.lower()): 

1058 raise ValueError( 

1059 f"may_be_initial marks a single cased letter that may read " 

1060 f"as an initial; {w!r} is not one") 

1061 return _Edit(op, ws, k, p) 

1062 

1063 

1064def _apply_edit(pool: dict[str, tuple[Any, ...]], 

1065 edit: _Edit) -> tuple[str, ...]: 

1066 """Apply `edit` to `pool` (word -> its readings) in place, returning 

1067 the words it touched. An add of a kind the word holds updates that 

1068 reading's properties in every file holding it, and turns a reading 

1069 written `default = false` on; otherwise it adds the caller's own 

1070 reading, which names no file. A remove with no words is one of a 

1071 whole kind, reaching every word in the pool that holds a reading of 

1072 it, whatever turned the reading on.""" 

1073 ri = _index_module() 

1074 words = edit.words or tuple( 

1075 w for w, rs in pool.items() 

1076 if any(r.kind == edit.kind or r.category == edit.kind for r in rs)) 

1077 for w in words: 

1078 rs = pool.get(w, ()) 

1079 if edit.op == "add" and edit.kind == "familiar": 

1080 # EXTENDS: the caller's longer names go in a reading of the 

1081 # caller's own, attributed to the edit and to no file, and 

1082 # the compile unions it with each file's, which stay as 

1083 # they are (decisions/name-matching, 2026-11-08 FAMILIAR 

1084 # FORMS); an explicit replace is remove, then add 

1085 added = set(dict(edit.props)["short_for"]) 

1086 mine = [r for r in rs if r.kind == "familiar" and not r.tag] 

1087 if mine: 

1088 added |= set(mine[0].props.get("short_for", ())) 

1089 reading = ri.IndexReading(w, "", "familiar", MappingProxyType( 

1090 {"short_for": tuple(sorted(added))})) 

1091 pool[w] = tuple(r for r in rs if r not in mine) + (reading,) 

1092 continue 

1093 if edit.op == "remove": 

1094 kept = tuple(r for r in rs if edit.kind is not None 

1095 and r.kind != edit.kind and r.category != edit.kind) 

1096 elif any(r.kind == edit.kind for r in rs): 

1097 kept = tuple( 

1098 ri.IndexReading(w, r.tag, r.kind, MappingProxyType( 

1099 _merged(r.props, edit.props))) 

1100 if r.kind == edit.kind else r for r in rs) 

1101 else: 

1102 kept = rs + (ri.IndexReading(w, "", edit.kind, MappingProxyType( 

1103 {k: v for k, v in edit.props if v is not None})),) 

1104 if kept: 

1105 pool[w] = kept 

1106 else: 

1107 pool.pop(w, None) 

1108 return words 

1109 

1110 

1111def _merged(props: Mapping[str, Any], update: _Props) -> dict[str, Any]: 

1112 out = {k: v for k, v in props.items() if k != "default"} 

1113 for k, v in update: 

1114 if v is None: 

1115 out.pop(k, None) 

1116 else: 

1117 out[k] = v 

1118 return out 

1119 

1120 

1121Pool = Mapping[str, tuple[Any, ...]] 

1122 

1123 

1124@functools.lru_cache(maxsize=8) 

1125def _base_pool(base: object) -> Pool: 

1126 """A base's readings by word, on by default or not: the shipped 

1127 index for `"default"`, none for `"empty"`, a snapshot's own rows.""" 

1128 if base == "empty": 

1129 return MappingProxyType({}) 

1130 ri = _index_module() 

1131 rows = ri._by_text().items() if base == "default" else base 

1132 return MappingProxyType({ 

1133 text: tuple(ri.from_row(text, row) for row in rs) 

1134 for text, rs in rows}) # type: ignore[union-attr] 

1135 

1136 

1137def _check_pool(pool: Pool, words: Iterable[str]) -> None: 

1138 """The build's checks a caller's edits can reach, over the words 

1139 they touch (checks 3 and 8 run per edit, in _make_edit): one suffix 

1140 kind per word (check 4), an agnomen has a name reading (check 11's 

1141 agnomen clause), and a bound given name that is also a particle has 

1142 a given reading (check 9).""" 

1143 for w in dict.fromkeys(words): 

1144 on = [r for r in pool.get(w, ()) if r.on_by_default] 

1145 suffix = {r.kind: r for r in on if r.category == "suffix"} 

1146 if len(suffix) > 1: 

1147 held = sorted(k for k, r in suffix.items() if r.tag) 

1148 drop = held[0] if held else sorted(suffix)[0] 

1149 raise ValueError( 

1150 f"{w!r} would hold two suffix kinds, " 

1151 f"{' and '.join(sorted(suffix))}, and a word holds one; " 

1152 f"remove the one it holds first: .remove({w!r}, " 

1153 f"kind={drop!r})") 

1154 kinds = {r.category for r in on} | {r.kind for r in on} 

1155 # check 11's agnomen clause: an agnomen is written in the case of 

1156 # the name around it, so only the count gates it, and the gate is 

1157 # a name reading beside it (rules/S11) 

1158 if "agnomen" in kinds and "name" not in kinds: 

1159 raise ValueError( 

1160 f"{w!r} is an agnomen with no name reading, and an agnomen " 

1161 f"is read as the suffix only where a name reading gates " 

1162 f"it by the count: write .add({w!r}, kind='family') " 

1163 f"first, then the agnomen") 

1164 if {"bound_given", "particle"} <= kinds and "given" not in kinds: 

1165 raise ValueError( 

1166 f"{w!r} is a bound given name and a particle with no given " 

1167 f"reading, and a particle that never starts a given name " 

1168 f"cannot bind one: .add({w!r}, kind='given'), or " 

1169 f".remove({w!r}, kind='bound_given')") 

1170 

1171 

1172@functools.lru_cache(maxsize=32) 

1173def _chosen_pool(base: object, chosen: frozenset[str], strict: bool) -> Pool: 

1174 """A base's readings under the choice of the file tags `chosen`, 

1175 before the caller's edits (decisions/vocabulary-model, 2026-11-02 

1176 #754, item 4): `choose` over the base's readings, under `strict` 

1177 only the chosen files', mul.toml's and native-script ones; a 

1178 reading an 

1179 unchosen file leaves off stays in the pool off, so a caller's add 

1180 of its kind still turns it on, as it does with no choice.""" 

1181 pool = _base_pool(base) 

1182 if not chosen: 

1183 return pool 

1184 ri = _index_module() 

1185 readings = [r for rs in pool.values() for r in rs] 

1186 if strict: 

1187 readings = ri.strict_kept(readings, chosen) 

1188 out: dict[str, list[Any]] = {} 

1189 for r in ri.choose(readings, chosen): 

1190 out.setdefault(r.text, []).append(r) 

1191 for r in readings: 

1192 if not r.on_by_default and r.tag not in chosen: 

1193 out.setdefault(r.text, []).append(r) 

1194 return MappingProxyType({t: tuple(rs) for t, rs in out.items()}) 

1195 

1196 

1197@functools.lru_cache(maxsize=128) 

1198def _built(base: object, edits: tuple[_Edit, ...], chosen: frozenset[str], 

1199 strict: bool) -> tuple[Pool, Mapping[str, frozenset[str]], 

1200 SameAs, SameAsNamed, Familiar]: 

1201 """A lexicon's readings by word and the sets they compile to, kept 

1202 per base, log and choice: a compile costs milliseconds, paid once 

1203 per configuration (decisions/vocabulary-model, 2026-11-01 #754). 

1204 The choice filters the base's readings and the log applies after 

1205 it, so a caller's reading survives any choice; one that clashes 

1206 with a reading the choice turns on is refused, naming the edit 

1207 (2026-11-02 #754, item 3).""" 

1208 pool = dict(_chosen_pool(base, chosen, strict)) 

1209 touched = [(edit, _apply_edit(pool, edit)) for edit in edits] 

1210 for w in dict.fromkeys(w for _, ws in touched for w in ws): 

1211 try: 

1212 _check_pool(pool, (w,)) 

1213 except ValueError as exc: 

1214 if not chosen or _clashes_unchosen(base, edits): 

1215 raise 

1216 mine = " and ".join(_rendered(e for e, ws in touched if w in ws)) 

1217 raise ValueError( 

1218 f"the lexicon's {mine} clashes with a reading the choice " 

1219 f"turns on: {exc}") from None 

1220 ri = _index_module() 

1221 try: 

1222 compiled = ri.compile_readings( 

1223 r for rs in pool.values() for r in rs) 

1224 except ri.CompileError as exc: 

1225 raise ValueError(str(exc)) from None 

1226 return (MappingProxyType(pool), *_compiled_of(compiled)) 

1227 

1228 

1229def _clashes_unchosen(base: object, edits: tuple[_Edit, ...]) -> bool: 

1230 """Whether `edits` clash on `base` with no choice, so that a clash 

1231 under one is the log's own rather than the choice's.""" 

1232 try: 

1233 _built(base, edits, frozenset(), False) 

1234 except ValueError: 

1235 return True 

1236 return False 

1237 

1238 

1239def _compiled_for(base: object, edits: tuple[_Edit, ...], 

1240 chosen: frozenset[str] = frozenset(), 

1241 strict: bool = False) -> _Compiled: 

1242 if not edits and base == "default": 

1243 # under a choice, the shipped index compiled once per choice by 

1244 # the reader every pack shares 

1245 return (_choice_compiled(chosen, strict) if chosen 

1246 else _default_compiled()) 

1247 if not edits and not chosen and base == "empty": 

1248 return _NOTHING_COMPILED 

1249 _, sets, same_as, named, familiar = _built(base, edits, chosen, strict) 

1250 return sets, same_as, named, familiar 

1251 

1252 

1253@functools.lru_cache(maxsize=32) 

1254def _choice_compiled(chosen: frozenset[str], strict: bool) -> _Compiled: 

1255 ri = _index_module() 

1256 return _compiled_of(ri.compile_choice(chosen, strict)) 

1257 

1258 

1259class _ReadingFiles: 

1260 """The files whose readings are on in one lexicon, by word: what 

1261 ``Reading.languages`` names (decisions/vocabulary-model, 2026-11-02 

1262 #754, sub-phase 4i-d). One per base, log and choice 

1263 (`_reading_files`), held by each `Lexicon` it describes and never 

1264 pickled; it hashes by identity, in C, so assemble's memo keyed by 

1265 it costs no frame on a hit. 

1266 

1267 The default answers from the reading index's rows 

1268 (`_reading_index.languages_of`), and a choice with no log from the 

1269 word's own rows under `choose`, which is exact because a choice's 

1270 preference is per word; neither builds the lexicon's whole pool, 

1271 which a log or a snapshot base has built already, at construction.""" 

1272 

1273 __slots__ = ("_base", "_edits", "_chosen", "_strict") 

1274 

1275 def __init__(self, base: object, edits: tuple[_Edit, ...], 

1276 chosen: frozenset[str], strict: bool) -> None: 

1277 self._base, self._edits = base, edits 

1278 self._chosen, self._strict = chosen, strict 

1279 

1280 def pool(self) -> Pool: 

1281 """The lexicon's readings by word (`Lexicon._pool`).""" 

1282 if not self._edits: 

1283 return _chosen_pool(self._base, self._chosen, self._strict) 

1284 return _built(self._base, self._edits, self._chosen, 

1285 self._strict)[0] 

1286 

1287 def languages(self, text: str, kinds: frozenset[str]) -> tuple[str, ...]: 

1288 """The tags of the files holding a reading of `text` (as the 

1289 vocabulary stores it) of one of `kinds`, on in this lexicon, 

1290 sorted; a caller's own reading names none.""" 

1291 ri = _index_module() 

1292 readings: Iterable[Any] 

1293 if self._edits or self._base not in ("default", "empty"): 

1294 readings = self.pool().get(text, ()) 

1295 elif self._base == "empty": 

1296 return () 

1297 elif not self._chosen: 

1298 return cast("tuple[str, ...]", ri.languages_of(text, kinds)) 

1299 else: 

1300 readings = ri.chosen_readings(ri.readings_of(text), self._chosen, 

1301 self._strict) 

1302 return tuple(sorted({r.tag for r in readings 

1303 if r.tag and r.kind in kinds 

1304 and r.on_by_default})) 

1305 

1306 

1307@functools.lru_cache(maxsize=128) 

1308def _reading_files(base: object, edits: tuple[_Edit, ...], 

1309 chosen: frozenset[str], strict: bool) -> _ReadingFiles: 

1310 # one per configuration, so equal lexicons share assemble's memo 

1311 return _ReadingFiles(base, edits, chosen, strict) 

1312 

1313 

1314def _check_choice(languages: object, strict: object) -> tuple[ 

1315 frozenset[str], frozenset[str]]: 

1316 """A recorded choice validated as `parser_for` validates one: its 

1317 languages in canonical case and the file tags they select, the 

1318 selection raising for a tag no file serves (decisions/ 

1319 vocabulary-model, 2026-11-02 #754, item 3).""" 

1320 if not isinstance(strict, bool): 

1321 raise TypeError(f"_strict must be True or False, got {strict!r}") 

1322 if (isinstance(languages, (str, bytes, bytearray, Mapping)) 

1323 or not isinstance(languages, Iterable)): 

1324 raise TypeError(f"_languages must be a set of BCP 47 tags, e.g. " 

1325 f"frozenset({{'pt'}}), got {languages!r}") 

1326 tags = frozenset(cast("Iterable[object]", languages)) 

1327 for tag in tags: 

1328 if not isinstance(tag, str): 

1329 raise TypeError(f"_languages entries must be str BCP 47 tags, " 

1330 f"got {tag!r}") 

1331 if not tags: 

1332 if strict: 

1333 raise ValueError( 

1334 "a strict choice keeps only the chosen languages' " 

1335 "vocabulary and the shared words (mul.toml), and " 

1336 "_languages chooses none; choose through " 

1337 "parser_for(..., strict=True) with a pack that chooses one") 

1338 return frozenset(), frozenset() 

1339 canon = frozenset(_canonical(cast(str, t)) for t in tags) 

1340 return canon, _index_module().selected(canon, strict=strict) 

1341 

1342 

1343def _canonical(tag: str) -> str: 

1344 """`tag` in BCP 47's canonical case: the language subtag lowercase, 

1345 a four-letter script subtag title case, a two-letter region 

1346 uppercase, every other subtag lowercase (RFC 5646, 2.1.1). Equal 

1347 choices then compare and hash equal, whatever case they were 

1348 written in.""" 

1349 first, *rest = tag.split("-") 

1350 out = [first.lower()] 

1351 for sub in rest: 

1352 if len(sub) == 4 and sub.isalpha(): 

1353 out.append(sub.title()) 

1354 elif len(sub) == 2 and sub.isalpha(): 

1355 out.append(sub.upper()) 

1356 else: 

1357 out.append(sub.lower()) 

1358 return "-".join(out) 

1359 

1360 

1361#: The most tags a repr lists (a `Lexicon`'s choice, a `Locale`'s); a 

1362#: longer choice is shown by its size, so the repr stays bounded however 

1363#: many languages are chosen. 

1364_REPR_TAGS = 3 

1365 

1366 

1367def _shown_choice(languages: frozenset[str]) -> str: 

1368 if len(languages) > _REPR_TAGS: 

1369 return f"languages: {len(languages)}" 

1370 return "languages " + "+".join(sorted(languages)) 

1371 

1372 

1373def _check_base(base: object) -> object: 

1374 """`"default"`, `"empty"`, or a snapshot of index rows from a pickle 

1375 (decisions/vocabulary-model, 2026-11-02 #754, items 3 and 5), which 

1376 becomes `"default"` where it is the running release's index.""" 

1377 if base in ("default", "empty"): 

1378 return base 

1379 if not isinstance(base, tuple): 

1380 raise TypeError(f"a Lexicon's base is 'default', 'empty' or a " 

1381 f"snapshot of index rows, got {base!r}") 

1382 ri = _index_module() 

1383 from nameparser.config._index import READINGS 

1384 if base == READINGS: 

1385 return "default" 

1386 shape = ("a Lexicon snapshot is (word, rows) pairs, each row (tag, " 

1387 "kind, properties), the properties (name, value) pairs") 

1388 for entry in base: 

1389 if not (isinstance(entry, tuple) and len(entry) == 2 

1390 and isinstance(entry[0], str) 

1391 and isinstance(entry[1], tuple)): 

1392 raise TypeError(f"{shape}; got {entry!r}") 

1393 word, rows = entry 

1394 for row in rows: 

1395 if not (isinstance(row, tuple) and len(row) == 3): 

1396 raise TypeError(f"{shape}; {word!r} has the row {row!r}") 

1397 tag, kind, pairs = row 

1398 if not isinstance(tag, str) or not isinstance(kind, str): 

1399 raise TypeError(f"{shape}, the tag and the kind strings; " 

1400 f"{word!r} has the row {row!r}") 

1401 if kind not in ri.CATEGORY: 

1402 raise ValueError( 

1403 f"a Lexicon snapshot holds the reading kind {kind!r}, " 

1404 f"which this release does not know") 

1405 if not isinstance(pairs, tuple) or not all( 

1406 isinstance(p, tuple) and len(p) == 2 

1407 and isinstance(p[0], str) for p in pairs): 

1408 raise TypeError(f"{shape}; {word!r} has the row {row!r}") 

1409 for name, value in pairs: 

1410 if name not in ri.indexed(kind): 

1411 raise ValueError( 

1412 f"a Lexicon snapshot gives {word!r}'s {kind} reading " 

1413 f"the property {name!r}, which a {kind} reading " 

1414 f"does not take in this release") 

1415 try: 

1416 hash(value) 

1417 except TypeError: 

1418 raise TypeError( 

1419 f"a Lexicon snapshot gives {word!r}'s {kind} reading " 

1420 f"{name}={value!r}; a value is a string, a bool or " 

1421 f"a tuple") from None 

1422 return base 

1423 

1424 

1425@functools.cache 

1426def _choice_masks(chosen: frozenset[str], strict: bool = False) -> tuple[ 

1427 frozenset[str], tuple[tuple[str, str], ...]]: 

1428 """The case masks the choice of the file tags `chosen` moves against 

1429 the default's compile: the ones that leave and the ones set. A mask 

1430 is no reading (a `Lexicon` keeps its masks in a field of their own), 

1431 so `parser_for` moves them in that field, as 3b's delta did.""" 

1432 ri = _index_module() 

1433 old = ri.compile_choice(frozenset())["CAPITALIZATION_EXCEPTIONS"] 

1434 new = ri.compile_choice(chosen, strict)["CAPITALIZATION_EXCEPTIONS"] 

1435 return (frozenset(old.keys() - new.keys()), 

1436 tuple(sorted((k, v) for k, v in new.items() if old.get(k) != v))) 

1437 

1438 

1439def _rechosen_masks(lexicon: Lexicon, chosen: frozenset[str], 

1440 strict: bool) -> tuple[tuple[str, str], ...]: 

1441 """`lexicon`'s masks moved from its own choice's to `chosen`'s: the 

1442 base's choice's moves undone, then the new choice's applied, so a 

1443 chained choice moves them as the union does.""" 

1444 cap = dict(lexicon.capitalization_exceptions) 

1445 if lexicon.languages: 

1446 default = _index_module().compile_choice( 

1447 frozenset())["CAPITALIZATION_EXCEPTIONS"] 

1448 gone, joined = _choice_masks(lexicon._chosen, lexicon.strict) 

1449 for k in gone: 

1450 cap.setdefault(k, default[k]) 

1451 for k, v in joined: 

1452 if cap.get(k) == v: 

1453 if k in default: 

1454 cap[k] = default[k] 

1455 else: 

1456 del cap[k] 

1457 gone, joined = _choice_masks(chosen, strict) 

1458 for k in gone: 

1459 cap.pop(k, None) 

1460 cap.update(joined) 

1461 return tuple(sorted(cap.items())) 

1462 

1463 

1464def _fields_of(sets: _LexiconSets | Mapping[str, frozenset[str]], 

1465 ) -> dict[str, frozenset[str]]: 

1466 get = (sets.__getitem__ if isinstance(sets, Mapping) 

1467 else functools.partial(getattr, sets)) 

1468 return {f: get(f) for f in _SET_FIELDS + _KIND_FIELDS} 

1469 

1470 

1471def _rendered(edits: Iterable[_Edit]) -> list[str]: 

1472 """Edits as the calls that make them, consecutive ones of one kind 

1473 and properties joined into one call.""" 

1474 calls: list[tuple[str, list[str], str | None, _Props]] = [] 

1475 for e in edits: 

1476 # a remove of a whole kind (no words) joins no call of words 

1477 if (calls and e.words and calls[-1][1] 

1478 and calls[-1][0] == e.op 

1479 and calls[-1][2:] == (e.kind, e.props)): 

1480 calls[-1][1].extend(e.words) 

1481 else: 

1482 calls.append((e.op, list(e.words), e.kind, e.props)) 

1483 out = [] 

1484 for op, words, kind, props in calls: 

1485 args = [repr(w) for w in words] 

1486 if kind is not None: 

1487 args.append(f"kind={kind!r}") 

1488 args += [f"{k}={_shown_value(kind, k, v)}" for k, v in props] 

1489 if op == "remove" and not words: 

1490 args.append("every=True") 

1491 out.append(f".{op}({', '.join(args)})") 

1492 return out 

1493 

1494 

1495def _shown_value(kind: str | None, prop: str, value: object) -> str: 

1496 """A logged property value as a caller writes it: a removed one as 

1497 its unwritten value where it has one (`may_be_initial=False`).""" 

1498 if value is None and kind is not None and prop in ( 

1499 "abbreviation", "acronym", "addresses", "after_given", 

1500 "may_be_initial", "title_case_is_name", "shared", 

1501 "after_particle", "capital_opening", "dotted"): 

1502 value = _unwritten(kind, prop) 

1503 if isinstance(value, tuple) and len(value) > _SHOWN_WORDS: 

1504 # bounded: a reading's list may name every title word it 

1505 # reaches 

1506 return (repr(list(value[:_SHOWN_WORDS]))[:-1] 

1507 + f", ... {len(value) - _SHOWN_WORDS} more]") 

1508 return repr(list(value) if isinstance(value, tuple) else value) 

1509 

1510 

1511#: The most words a clash message shows of one property's list. 

1512_SHOWN_WORDS = 6 

1513 

1514 

1515def _refusal(where: str, removed: Mapping[str, object]) -> TypeError: 

1516 """The TypeError for a 2.x spelling: a list or the masks with the 

1517 call that changes them, a set field with docs/migrate.rst's table, 

1518 which names the kind of each (decisions/vocabulary-model, 

1519 2026-11-08 #1003).""" 

1520 unknown = [k for k in removed 

1521 if k not in _SET_FIELDS + _LIST_FIELDS 

1522 and k != "capitalization_exceptions"] 

1523 if unknown: 

1524 return TypeError(f"{where} got an unexpected keyword argument " 

1525 f"{unknown[0]!r}") 

1526 if "capitalization_exceptions" in removed: 

1527 return TypeError( 

1528 "capitalization_exceptions holds key->value pairs; use " 

1529 "dataclasses.replace(lexicon, capitalization_exceptions=(*lexicon." 

1530 "capitalization_exceptions, ('key', 'KEY'))) instead of " 

1531 f"{where}") 

1532 lists = [k for k in removed if k in _LIST_FIELDS] 

1533 if lists: 

1534 name = lists[0] 

1535 given = removed[name] 

1536 words = (sorted(given) if isinstance(given, Iterable) 

1537 and not isinstance(given, (str, bytes, Mapping)) 

1538 and all(isinstance(w, str) for w in given) and given 

1539 else ["word"]) 

1540 shown = "{" + ", ".join(map(repr, words)) + "}" 

1541 code = (f"dataclasses.replace(lexicon, {name}=lexicon.{name} - " 

1542 f"{shown})" if where.startswith("remove") 

1543 else f"lexicon | Lexicon({name}={shown})") 

1544 return TypeError( 

1545 f"{name} is a list, changed with a fragment since 3.0, not " 

1546 f"{where}: write {code}") 

1547 return TypeError( 

1548 f"{where} takes no {', '.join(removed)} since 3.0: a Lexicon is " 

1549 f"configured by reading, add(*words, kind=..., **properties) and " 

1550 f"remove(*words, kind=...); docs/migrate.rst's Lexicon table " 

1551 f"gives the kind for each 2.x field") 

1552 

1553 

1554#: The keys of the one 2.x `Lexicon` layout 3.0 loaded, 2.7's and 

1555#: 2.8's, read off a 2.8.0 `Lexicon.default()` state; 3.0 admitted 

1556#: `given_names` beside them. A record of what shipped, so it never 

1557#: follows `_VOCAB_FIELDS`, which a new vocabulary field joins. 

1558_LAYOUT_3_0_LOADED = frozenset({ 

1559 "after_given_titles", "agnomens", "bound_given_names", 

1560 "capitalization_exceptions", "conjunctions", "conjunctions_ambiguous", 

1561 "given_name_abbreviations", "given_name_titles", "honorific_tails", 

1562 "maiden_markers", "particles", "particles_ambiguous", "suffix_acronyms", 

1563 "suffix_acronyms_ambiguous", "suffix_words", "suffix_words_ambiguous", 

1564 "surnames", "title_modifiers", "title_phrases", "title_styles", 

1565 "titles", "titles_ambiguous"}) 

1566 

1567 

1568def _refused_2x(state: Mapping[str, object]) -> ValueError: 

1569 """The ValueError for a 2.x `Lexicon` state, its remedy told by the 

1570 state's layout, since a caller holding an old pickle seldom knows 

1571 which release wrote it (decisions/vocabulary-model, 2026-11-08 

1572 #1003). It is raised wherever the `Lexicon` is, so a `Parser` or 

1573 locale pack holding one is named too. Any other layout is named by 

1574 the keys it lacks and holds beyond that one, as 3.0 named them.""" 

1575 held = "the Lexicon, or the Parser or locale pack holding it" 

1576 missing = sorted(_LAYOUT_3_0_LOADED - set(state)) 

1577 unexpected = sorted(set(state) - _LAYOUT_3_0_LOADED - {"given_names"}) 

1578 if not missing and not unexpected: 

1579 return ValueError( 

1580 f"this Lexicon was pickled by nameparser 2.7 or 2.8, which " 

1581 f"3.1 no longer loads: load the pickle under 3.0 and pickle " 

1582 f"the loaded object ({held}) again; if 3.0 refuses the " 

1583 f"pickle too, build the object again") 

1584 return ValueError( 

1585 f"this Lexicon was pickled by nameparser 2.x in a layout no 3.x " 

1586 f"release loads (missing: {', '.join(missing) or 'none'}; " 

1587 f"unexpected: {', '.join(unexpected) or 'none'}), as 2.6 and " 

1588 f"earlier wrote: build the object ({held}) again") 

1589 

1590 

1591# -- the public types ---------------------------------------------------- 

1592 

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

1594class LexiconReading: 

1595 """One reading a :class:`Lexicon` holds for a word, as 

1596 :meth:`Lexicon.readings_of` returns it: what the word may be read 

1597 as, in the vocabulary files' terms (decisions/vocabulary-model, 

1598 2026-11-02 #754, item 2). 

1599 

1600 ``kind`` is a vocabulary kind as the language files write it 

1601 (``"office"``, ``"credential"``, ``"preposition"``, ``"given"``), 

1602 finer than :class:`ReadingKind`, which is the parse's; ``category`` 

1603 its category (``"title"``, ``"suffix"``, ``"particle"``, 

1604 ``"conjunction"``, ``"bound_given"``, ``"relation"``, ``"name"``); 

1605 ``properties`` its behavioral properties as sorted ``(name, value)`` 

1606 pairs, spelled as the files spell them (``("addresses", "given")``), 

1607 a list value a sorted tuple; ``languages`` the BCP 47 tags of the 

1608 shipped files holding it, sorted, empty for a reading a caller 

1609 added. Whether the parse READ a word this way is the parse's 

1610 question, :attr:`Token.reading`: a title reading beside a ``given`` 

1611 one is a title only inside a title phrase (rules/H9). Added in 

1612 3.0.""" 

1613 

1614 text: str 

1615 kind: str 

1616 category: str 

1617 properties: tuple[tuple[str, Any], ...] = () 

1618 languages: tuple[str, ...] = () 

1619 

1620 def __post_init__(self) -> None: 

1621 ri = _index_module() 

1622 for name in ("text", "kind", "category"): 

1623 if not isinstance(getattr(self, name), str): 

1624 raise TypeError(f"LexiconReading.{name} must be a string, " 

1625 f"got {getattr(self, name)!r}") 

1626 if self.kind not in ri.CATEGORY: 

1627 raise ValueError(f"unknown reading kind {self.kind!r}; the " 

1628 f"kinds are {', '.join(ri.CATEGORY)}") 

1629 if self.category != ri.CATEGORY[self.kind]: 

1630 raise ValueError( 

1631 f"a {self.kind} reading's category is " 

1632 f"{ri.CATEGORY[self.kind]!r}, got {self.category!r}") 

1633 for name in ("properties", "languages"): 

1634 if not isinstance(getattr(self, name), tuple): 

1635 raise TypeError(f"LexiconReading.{name} must be a tuple, " 

1636 f"got {getattr(self, name)!r}") 

1637 

1638 def __getstate__(self) -> dict[str, object]: 

1639 return {f.name: getattr(self, f.name) 

1640 for f in dataclasses.fields(self)} 

1641 

1642 def __setstate__(self, state: dict[str, object]) -> None: 

1643 # the layout check the other frozen types share 

1644 # (_types._guarded_setstate), held here by layering 

1645 expected = {f.name for f in dataclasses.fields(LexiconReading)} 

1646 if set(state) != expected: 

1647 missing = ", ".join(sorted(expected - set(state))) or "none" 

1648 unexpected = ", ".join(sorted(set(state) - expected)) or "none" 

1649 raise ValueError( 

1650 f"incompatible LexiconReading pickle: missing fields: " 

1651 f"{missing}; unexpected fields: {unexpected}") 

1652 for name, value in state.items(): 

1653 object.__setattr__(self, name, value) 

1654 

1655 

1656@dataclass(frozen=True, slots=True, init=False, repr=False) 

1657class Lexicon: 

1658 """The vocabulary a parser matches against: which words may be read 

1659 as titles, particles, suffixes, and so on. Immutable and hashable. 

1660 Start from :meth:`default` (the shipped vocabulary) or 

1661 :meth:`empty`, configure it by READING -- :meth:`add` and 

1662 :meth:`remove` take words and a vocabulary kind as the language 

1663 files write it, ``add("leed", "ap", kind="credential")`` -- join 

1664 fragments with ``|``, and pass the result to ``Parser(lexicon=...)``. 

1665 Ask what a word may be read as with :meth:`readings_of`. 

1666 

1667 Words are normalized as they are stored -- lowercased, NFC-composed, 

1668 edge full stops stripped -- so matching is case-insensitive. 

1669 

1670 Since 3.0 the sets a parse reads are compiled from the readings and 

1671 are internal; the 2.x set fields (``titles``, ``suffix_acronyms``, 

1672 ...) are refused wherever they were written, each refusal naming 

1673 the field and pointing at docs/migrate.rst's table of the kind each 

1674 field's words take. Three fields stay, being outside the per-word model: 

1675 ``surnames``, ``given_names`` and ``capitalization_exceptions``. 

1676 

1677 State is a base (the shipped vocabulary, none, or a pickled 

1678 release's), the choice of languages :func:`~nameparser.parser_for` 

1679 records (:attr:`languages`, :attr:`strict`), the ordered log of 

1680 :meth:`add` and :meth:`remove` calls applied after the choice, and 

1681 the three fields; two lexicons are equal when all of them are, the log compared call by call as written, so two 

1682 logs that read alike can still differ (decisions/vocabulary-model, 

1683 2026-11-02 #754, item 3).""" 

1684 

1685 #: Family names for the unspaced-name segmentation stage (#271), 

1686 #: matched longest-first against the start of the FIRST token 

1687 #: written wholly in a script :attr:`Policy.segment_scripts 

1688 #: <nameparser.Policy.segment_scripts>` activates. The default 

1689 #: carries the Korean census list 

1690 #: (``nameparser/config/_ko_surnames.py``); Chinese 

1691 #: surnames ship in locales.ZH because Han segmentation is opt-in. 

1692 #: A list, outside the per-word model: add to it with a fragment, 

1693 #: ``lexicon | Lexicon(surnames={...})``. 

1694 surnames: frozenset[str] = frozenset() 

1695 #: Given names read by two-family-name handling alone 

1696 #: (:attr:`Policy.two_family_names 

1697 #: <nameparser.Policy.two_family_names>`, rules/O6): the word that 

1698 #: rule would take into the family stays where it is when listed, so 

1699 #: "José Luis Pérez" keeps family "Pérez". A word matches as 

1700 #: written or with its acute and grave accents and diaeresis 

1701 #: dropped (the tilde on n and the cedilla on c kept), so an entry 

1702 #: written "jose" matches "José" too. No other rule reads it. Empty 

1703 #: by default, so the switch reads as without it; a caller passes 

1704 #: a list of its own, and ``locales.ES`` carries a Spanish list, with 

1705 #: the switch on. Added in 3.0. 

1706 given_names: frozenset[str] = frozenset() 

1707 #: Lowercase word -> case mask used by capitalized(): the word's 

1708 #: own letters and digits, each in the case it takes ("phd" -> 

1709 #: "PhD"), laid over the word as written, so the one entry 

1710 #: repairs "ph.d." to "Ph.D.". The mask's own punctuation marks 

1711 #: where its letters are joined into one run versus split apart, 

1712 #: and is never written into the word -- repair keeps the writer's 

1713 #: own punctuation. A value that does not spell the key's letters 

1714 #: and digits raises ValueError. Pair-valued: change it with 

1715 #: dataclasses.replace(); read it as a mapping via 

1716 #: capitalization_exceptions_map; the default's are 

1717 #: ``Lexicon.default().capitalization_exceptions``. 

1718 # Canonical storage: sorted tuple of (key, value) pairs. The 

1719 # constructor tolerates any Mapping (or pair iterable) at runtime and 

1720 # canonicalizes here; this closes the caller-aliasing hole and keeps 

1721 # Lexicon hashable. Read via capitalization_exceptions_map. 

1722 capitalization_exceptions: tuple[tuple[str, str], ...] = () 

1723 # The base, the log and the choice: constructor parameters under 

1724 # underscore names, so that dataclasses.replace and copy.replace 

1725 # carry them, and no public API (decisions/vocabulary-model, 

1726 # 2026-11-02 #754, item 3); a choice is set publicly by parser_for 

1727 # alone, and read through `languages` and `strict`. 

1728 _base: Any = "empty" 

1729 _edits: tuple[_Edit, ...] = () 

1730 # marked added: a 4i-b pickle, from no release, holds no choice and 

1731 # loads choosing nothing, its log already holding what a choice of 

1732 # 3b's delta had written into it 

1733 _languages: frozenset[str] = field( 

1734 default=frozenset(), metadata={_ADDED_IN: "3.0.0"}) 

1735 _strict: bool = field(default=False, metadata={_ADDED_IN: "3.0.0"}) 

1736 # the file tags the choice selects, derived from it 

1737 _chosen: frozenset[str] = field( 

1738 init=False, repr=False, compare=False, hash=False) 

1739 _cap_map: Mapping[str, str] = field( 

1740 init=False, repr=False, compare=False, hash=False) 

1741 _sets: _LexiconSets = field( 

1742 init=False, repr=False, compare=False, hash=False) 

1743 # group -> word -> the words it is a listed spelling of (`given`: 

1744 # `wm` -> {`william`}), from every reading's `same_as` in the group 

1745 # of its kind; built at construction, never pickled, like `_cap_map` 

1746 _same_as: SameAs = field( 

1747 init=False, repr=False, compare=False, hash=False) 

1748 # group -> the words with a link there that are a name of their own 

1749 # too (`jo` among given names), whose links need the period 

1750 # (`_headwords`); built like `_same_as` 

1751 _same_as_named: SameAsNamed = field( 

1752 init=False, repr=False, compare=False, hash=False) 

1753 # a given name -> every name it is paired with as a familiar form, 

1754 # either way (`max` -> {`maksim`, `maxim`, `maximilian`}, 

1755 # `maximilian` -> {`max`}), from every `familiar` reading's 

1756 # `short_for`, read by the name match alone (`_familiar_forms`); 

1757 # built like `_same_as` 

1758 _familiar: Familiar = field( 

1759 init=False, repr=False, compare=False, hash=False) 

1760 # what assemble keys Reading.languages by: one per base, log and 

1761 # choice (_ReadingFiles) 

1762 _files: _ReadingFiles = field( 

1763 init=False, repr=False, compare=False, hash=False) 

1764 

1765 def __init__( 

1766 self, *, 

1767 surnames: Iterable[str] = frozenset(), 

1768 given_names: Iterable[str] = frozenset(), 

1769 capitalization_exceptions: (Mapping[str, str] 

1770 | Iterable[tuple[str, str]]) = (), 

1771 _base: object = "empty", 

1772 _edits: Iterable[_Edit] = (), 

1773 _languages: Iterable[str] = frozenset(), 

1774 _strict: bool = False, 

1775 **removed: object, 

1776 ) -> None: 

1777 put = functools.partial(object.__setattr__, self) 

1778 put("surnames", _normset(surnames, "surnames")) 

1779 put("given_names", _normset(given_names, "given_names")) 

1780 canonical = _normpairs(capitalization_exceptions) 

1781 put("capitalization_exceptions", canonical) 

1782 put("_cap_map", MappingProxyType(dict(canonical))) 

1783 put("_base", _check_base(_base)) 

1784 if isinstance(_edits, (str, bytes)) or not isinstance( 

1785 _edits, Iterable): 

1786 raise TypeError(f"_edits must be a tuple of edits, got " 

1787 f"{_edits!r}") 

1788 edits = tuple(_edits) 

1789 for e in edits: 

1790 if not isinstance(e, tuple) or len(e) != 4: 

1791 raise TypeError(f"an edit is (op, words, kind, props), " 

1792 f"got {e!r}") 

1793 canon = tuple(_make_edit(*e) for e in edits) 

1794 if canon != edits: 

1795 raise ValueError( 

1796 "_edits holds an edit not in canonical form; build a " 

1797 "Lexicon's log with add() and remove()") 

1798 put("_edits", canon) 

1799 languages, chosen = _check_choice(_languages, _strict) 

1800 put("_languages", languages) 

1801 put("_strict", _strict) 

1802 put("_chosen", chosen) 

1803 compiled, same_as, named, familiar = _compiled_for( 

1804 self._base, canon, chosen, _strict) 

1805 put("_files", _reading_files(self._base, canon, chosen, _strict)) 

1806 put("_same_as", same_as) 

1807 put("_same_as_named", named) 

1808 put("_familiar", familiar) 

1809 put("_sets", _LexiconSets( 

1810 **compiled, surnames=self.surnames, given_names=self.given_names)) 

1811 if removed: 

1812 raise _refusal("Lexicon() (and dataclasses.replace)", removed) 

1813 

1814 # -- constructors ---------------------------------------------------- 

1815 

1816 @classmethod 

1817 def empty(cls) -> Lexicon: 

1818 """A lexicon holding no readings, no surnames, no given names 

1819 and no case masks, equal to ``Lexicon()``: the start for a 

1820 fragment to join onto another with ``|``, or for a vocabulary 

1821 built from nothing. A parser over it still reads by shape and 

1822 position (``Dr. John Smith`` keeps title ``Dr.``, a period-marked 

1823 word opening a name), and, the default :class:`Policy 

1824 <nameparser.Policy>` dividing unspaced Hangul, warns at 

1825 construction that no surnames cover it.""" 

1826 return cls() 

1827 

1828 @classmethod 

1829 def default(cls) -> Lexicon: 

1830 """The shipped vocabulary, the lexicon ``Parser()`` uses when 

1831 given none: every reading the language files turn on by 

1832 default, the Korean census surnames and the case masks, with no 

1833 :attr:`given_names` and no choice of languages. Cached, so 

1834 every call returns the same object, which being immutable is 

1835 safe to share; configure a copy with :meth:`add`, 

1836 :meth:`remove` or ``|``.""" 

1837 return _default_lexicon() 

1838 

1839 # -- dunders ---------------------------------------------------------- 

1840 

1841 # hidden from a type checker, which would otherwise read any 

1842 # attribute as defined and stop flagging `lexicon.titles` itself 

1843 if not TYPE_CHECKING: 

1844 def __getattr__(self, name: str) -> object: 

1845 if name in _SET_FIELDS: 

1846 raise AttributeError( 

1847 f"Lexicon.{name} is internal since 3.0, compiled from " 

1848 f"the lexicon's readings: ask lexicon.readings_of(word) " 

1849 f"what a word may be read as, and a parse's " 

1850 f"Token.reading what it was read as") 

1851 raise AttributeError( 

1852 f"{type(self).__name__!r} object has no attribute {name!r}") 

1853 

1854 def __repr__(self) -> str: 

1855 # Bounded: counts readings by kind against the nearer of the two 

1856 # named lexicons, never naming a word (the design rule in 

1857 # nameparser._types' module docstring) 

1858 # A choice is shown by its tags, and the readings counted against 

1859 # the named lexicon under the same choice 

1860 choice = "" 

1861 named = [("default", Lexicon.default()), ("empty", Lexicon.empty())] 

1862 if self._languages: 

1863 choice = ", " + _shown_choice(self._languages) + ( 

1864 ", strict" if self._strict else "") 

1865 named = [(label, dataclasses.replace( 

1866 other, _languages=self._languages, _strict=self._strict, 

1867 capitalization_exceptions=_rechosen_masks( 

1868 other, self._chosen, self._strict))) 

1869 for label, other in named] 

1870 for label, other in named: 

1871 if self == other: 

1872 return f"Lexicon({label}{choice})" 

1873 candidates = [(label, self._deltas_from(other)) 

1874 for label, other in named] 

1875 label, (kinds, lists) = min( 

1876 candidates, 

1877 key=lambda c: sum(a + r for _, a, r in c[1][0] + c[1][1])) 

1878 

1879 def counts(deltas: list[tuple[str, int, int]]) -> str: 

1880 return ", ".join( 

1881 name + ": " + "".join( 

1882 part for part, n in ((f"+{a}", a), (f"-{r}", r)) if n) 

1883 for name, a, r in deltas) 

1884 

1885 rendered = "; ".join(filter(None, (counts(kinds), counts(lists)))) 

1886 return (f"Lexicon({label} + {rendered or f'edits: {len(self._edits)}'}" 

1887 f"{choice})") 

1888 

1889 def __getstate__(self) -> dict[str, object]: 

1890 # the base as a snapshot of its rows, so a pickle parses as it 

1891 # did where it was written (decisions/vocabulary-model, 

1892 # 2026-11-02 #754, item 5) 

1893 base = self._base 

1894 if base == "default": 

1895 from nameparser.config._index import READINGS 

1896 base = READINGS 

1897 return {"surnames": self.surnames, "given_names": self.given_names, 

1898 "capitalization_exceptions": self.capitalization_exceptions, 

1899 "_base": base, "_edits": tuple(tuple(e) for e in self._edits), 

1900 "_languages": self._languages, "_strict": self._strict} 

1901 

1902 def __setstate__(self, state: dict[str, object]) -> None: 

1903 # A 2.x state holds the compiled sets, refused since 3.1 

1904 # (decisions/vocabulary-model, 2026-11-08 #1003); a 3.0 one is 

1905 # checked for its layout as _types._guarded_setstate checks the 

1906 # other frozen types', the one skew admitted a field marked 

1907 # `_ADDED_IN` with a default (`_languages`, `_strict`), and then 

1908 # built through the constructor, which validates every value as 

1909 # for a caller's. 

1910 if "titles" in state: 

1911 raise _refused_2x(state) 

1912 fields = [f for f in dataclasses.fields(Lexicon) if f.init] 

1913 expected = {f.name for f in fields} 

1914 added = {f.name: f.default for f in fields 

1915 if _ADDED_IN in f.metadata 

1916 and f.default is not dataclasses.MISSING} 

1917 missing_set = expected - set(state) - set(added) 

1918 if missing_set or not set(state) <= expected: 

1919 missing = ", ".join(sorted(missing_set)) or "none" 

1920 unexpected = ", ".join(sorted(set(state) - expected)) or "none" 

1921 raise ValueError( 

1922 f"incompatible Lexicon pickle: missing fields: {missing}; " 

1923 f"unexpected fields: {unexpected}") 

1924 defaulted = {name for name in added if name not in state} 

1925 state = {**{name: added[name] for name in defaulted}, **state} 

1926 Lexicon.__init__(self, **state) # type: ignore[misc, arg-type] 

1927 # The constructor normalizes, and quietly accepting a rewritten 

1928 # value would make this a place where caller data changes 

1929 # without a word. A pickle this library wrote is always 

1930 # normalized already (_normalize converges), so a difference 

1931 # here means the state came from somewhere else -- say so while 

1932 # the offending entries can still be named. 

1933 drifted = [] 

1934 for name in _LIST_FIELDS: 

1935 lost = frozenset( 

1936 cast("Iterable[str]", state[name])) - getattr(self, name) 

1937 if lost: 

1938 drifted.append(f"{name}: {', '.join(sorted(lost))}") 

1939 given_pairs = state["capitalization_exceptions"] 

1940 if (isinstance(given_pairs, tuple) 

1941 and given_pairs != self.capitalization_exceptions): 

1942 drifted.append("capitalization_exceptions") 

1943 faulty = [d for d in drifted if d.split(":")[0] in defaulted] 

1944 if faulty: 

1945 raise ValueError( 

1946 "Lexicon field default is not normalized " 

1947 f"({'; '.join(faulty)}): a field marked _ADDED_IN loads " 

1948 "its default into every pickle written before it, so " 

1949 "the default must be normalized entries; this is a " 

1950 "defect in nameparser, not in the pickle") 

1951 if drifted: 

1952 raise ValueError( 

1953 "incompatible Lexicon pickle: entries are not normalized " 

1954 f"({'; '.join(drifted)}); this state was not written by " 

1955 "this version of nameparser") 

1956 

1957 def __or__(self, other: Lexicon) -> Lexicon: 

1958 """The union of two lexicons: the lists unioned, the masks taken 

1959 right-biased, and the logs concatenated, this one's first, so a 

1960 later lexicon's edits apply after an earlier one's (decisions/ 

1961 vocabulary-model, 2026-11-02 #754, item 3); the base is the one 

1962 that is not empty and the choice the one that is made, and two 

1963 different bases, or two different choices, raise.""" 

1964 if not isinstance(other, Lexicon): 

1965 return NotImplemented 

1966 if (self._languages and other._languages 

1967 and (self._languages, self._strict) 

1968 != (other._languages, other._strict)): 

1969 raise ValueError( 

1970 "cannot join two lexicons compiled under different choices " 

1971 "of languages or of strictness; combine choices with " 

1972 "parser_for, which unions them: parser_for(*packs, " 

1973 "base=parser)") 

1974 mine = self if self._languages else other 

1975 if "empty" in (self._base, other._base) or self._base == other._base: 

1976 base = other._base if self._base == "empty" else self._base 

1977 else: 

1978 raise ValueError( 

1979 "cannot join two lexicons built on different bases (the " 

1980 "shipped vocabulary of two releases); rebuild one from " 

1981 "Lexicon.default() or Lexicon.empty()") 

1982 merged = dict(self._cap_map) | dict(other._cap_map) 

1983 return Lexicon( 

1984 surnames=self.surnames | other.surnames, 

1985 given_names=self.given_names | other.given_names, 

1986 capitalization_exceptions=tuple(sorted(merged.items())), 

1987 _base=base, _edits=self._edits + other._edits, 

1988 _languages=mine._languages, _strict=mine._strict) 

1989 

1990 # -- properties ------------------------------------------------------- 

1991 

1992 @property 

1993 def capitalization_exceptions_map(self) -> Mapping[str, str]: 

1994 return self._cap_map 

1995 

1996 @property 

1997 def languages(self) -> frozenset[str]: 

1998 """The languages this lexicon's readings are chosen under, as 

1999 BCP 47 tags; empty where none was chosen. Set by 

2000 :func:`~nameparser.parser_for`, which unions a base's choice 

2001 with its packs' (decisions/vocabulary-model, 2026-11-02 #754, 

2002 item 4): the shipped readings are filtered by the choice, and 

2003 the lexicon's own :meth:`add` and :meth:`remove` calls apply 

2004 after it. Added in 3.0.""" 

2005 return self._languages 

2006 

2007 @property 

2008 def strict(self) -> bool: 

2009 """Whether the choice is strict: only the chosen languages' 

2010 vocabulary files, mul.toml and native-script words' readings (as a 

2011 plain choice of the same languages keeps them) are read. Added in 3.0.""" 

2012 return self._strict 

2013 

2014 # -- access ----------------------------------------------------------- 

2015 

2016 def readings_of(self, word: str) -> tuple[LexiconReading, ...]: 

2017 """The readings this lexicon holds for `word`: one per kind and 

2018 properties, each with the shipped files that hold it, a reading 

2019 a caller added naming none; empty for a word it holds no reading 

2020 of. `word` is normalized as the lexicon stores it, a phrase 

2021 (``"z domu"``) whole. "Is X a title here?" is ``any(r.category 

2022 == "title" for r in lexicon.readings_of(x))``; whether a parse 

2023 read it as one is :attr:`Token.reading`'s. Added in 3.0.""" 

2024 _reject_buffer(word, "readings_of()", "characters") 

2025 if not isinstance(word, str): 

2026 raise TypeError(f"readings_of() takes a word, a string, got " 

2027 f"{word!r}") 

2028 text = _title_key(word.split()) 

2029 ri = _index_module() 

2030 grouped: dict[tuple[str, _Props], list[str]] = {} 

2031 for r in self._pool().get(text, ()): 

2032 if r.on_by_default: 

2033 grouped.setdefault((r.kind, _clean(r.props)), []).append( 

2034 r.tag) 

2035 order = list(ri.CATEGORY) 

2036 return tuple( 

2037 LexiconReading(text, kind, ri.CATEGORY[kind], props, 

2038 tuple(sorted(t for t in set(tags) if t))) 

2039 for (kind, props), tags in sorted( 

2040 grouped.items(), 

2041 key=lambda item: (order.index(item[0][0]), repr(item[0][1])))) 

2042 

2043 def words(self, *, kind: str, **properties: object) -> tuple[str, ...]: 

2044 """The words holding a reading of `kind` on in this lexicon, 

2045 sorted: a kind or a category, as :meth:`remove` takes it 

2046 (``words(kind="title")`` lists every title word, ``dean`` 

2047 among them, whichever set its readings compile to), and 

2048 `properties`, which a kind takes (``title``, ``suffix`` and 

2049 ``particle`` are kinds as well as categories, and take their 

2050 own kind's properties), narrowing to the readings that carry 

2051 them as :meth:`add` spells them, each compared in its own 

2052 reading's kind (so through a category an unwritten value means 

2053 each kind's own: ``acronym=None`` under ``suffix`` lists the 

2054 credentials, unwritten-true, and the suffix words, unwritten- 

2055 false, alike) 

2056 (``words(kind="title", addresses="given")``, the titles that 

2057 address a person by the given name). A phrase the lexicon holds 

2058 whole (``"z domu"``) is one entry. Added in 3.0.""" 

2059 k = _check_kind(kind, "words") 

2060 assert k is not None 

2061 ri = _index_module() 

2062 if properties: 

2063 if k not in ri.CATEGORY: 

2064 raise ValueError( 

2065 f"words() takes properties with a kind, not the " 

2066 f"category {k!r}; name one of its kinds: " + ", ".join( 

2067 x for x, c in ri.CATEGORY.items() if c == k)) 

2068 # checked against the kind named; compared below against 

2069 # each reading's own kind, since a kind that is also a 

2070 # category reaches kinds whose unwritten value differs 

2071 # (`acronym` is unwritten-true on a credential, false on a 

2072 # suffix word) 

2073 _edit_props(k, properties) 

2074 

2075 return tuple(sorted( 

2076 w for w, rs in self._pool().items() 

2077 if any(r.on_by_default and (r.kind == k or r.category == k) 

2078 # a reading holds its properties canonical, as the 

2079 # build and add write them, the unwritten value never 

2080 and all(r.props.get(p) == _prop_value(r.kind, p, v) 

2081 for p, v in properties.items()) 

2082 for r in rs))) 

2083 

2084 # -- editing ---------------------------------------------------------- 

2085 

2086 # `kind` is required, and a type checker reads it so: the runtime 

2087 # default exists only to catch a 2.x call, which names no kind, and 

2088 # refuse it pointing at docs/migrate.rst's table of kinds rather than 

2089 # with Python's own missing-argument error 

2090 if TYPE_CHECKING: 

2091 def add(self, *words: str, kind: str, 

2092 **properties: object) -> Lexicon: ... 

2093 else: 

2094 def add(self, *words: str, kind: str | None = None, 

2095 **properties: object) -> Lexicon: 

2096 """This lexicon with a reading of `kind` added to each word. 

2097 `kind` is required; the signature's default exists only so 

2098 that a 2.x call, which names no kind, is refused pointing at 

2099 docs/migrate.rst's table of kinds. It is a vocabulary kind as 

2100 the language files write it, a key of 

2101 ``nameparser._reading_index.CATEGORY`` (``"title"``, 

2102 ``"office"``, ``"credential"``, ``"generation"``, 

2103 ``"preposition"``, ``"conjunction"``, ``"bound_given"``, 

2104 ``"birth_marker"``, ``"given"``, ``"family"``, ...), and 

2105 `properties` that kind's behavioral properties as the files 

2106 spell them (``addresses="given"``, ``after_given=True``, 

2107 ``abbreviation=True``, ``attaches=[...]``, ``acronym=...``, ``attached="after"`` on a suffix word reading 

2108 and ``attached="before"`` on a one-word title reading (rules/W2 

2109 and W6), 

2110 ``may_be_initial=True``, ``completed_by=[...]`` on a 

2111 ``bound_given`` reading, ``after_particle=True`` on a 

2112 ``given`` reading, ``shared=True`` on a name reading, 

2113 ``capital_opening=True`` on a particle reading, 

2114 ``dotted=True`` on a title reading, and 

2115 ``same_as=...`` on a reading of any kind: the word this one 

2116 is a spelling of, which name matching reads as a 

2117 ``listed-spelling`` and keys alike in the roles that read 

2118 the kind's words (a ``given`` reading's in the given name, 

2119 middle name and nickname, a ``family`` reading's in the 

2120 family and maiden names, a title's and a suffix's in theirs), 

2121 and which on a ``given`` reading also marks a given name 

2122 written short; written on a word, it applies to the word's 

2123 given readings, so the word keeps no plain given reading 

2124 and its link counts written bare, where a word the 

2125 vocabulary also holds as a name of its own counts only 

2126 written with its period), and ``short_for=[...]`` on a 

2127 ``familiar`` reading, the longer given names a familiar 

2128 form stands for, which name matching reads as a 

2129 ``familiar-form`` where that outcome is accepted and no 

2130 parse reads (``add("max", kind="familiar", 

2131 short_for=["maxwell"])``), which EXTENDS the longer names 

2132 the word's shipped familiar readings list, in a reading of 

2133 the caller's own that names no file, rather than replacing 

2134 them (unlike an add of any other kind the word holds); to 

2135 replace them, ``remove(word, kind="familiar")`` first. A 

2136 pair lives on the word whose reading lists the other (its 

2137 shorter name, as the files write it): to cut 

2138 ``Max`` / ``Maximilian``, remove ``max``'s familiar 

2139 reading and add it back without ``maximilian``; a property 

2140 given its unwritten value (``None``, ``False``) is removed. Where the word holds a reading of `kind` 

2141 already, the add updates its properties in every file holding 

2142 it; otherwise it adds the caller's own reading, which names no 

2143 file. Warns where it makes a title reading that a ``given`` 

2144 reading keeps a title only inside a title phrase (rules/H9), 

2145 naming the ``remove`` that makes it a title everywhere.""" 

2146 old = {k: properties.pop(k) for k in list(properties) 

2147 if k in _SET_FIELDS + _LIST_FIELDS 

2148 or k == "capitalization_exceptions"} 

2149 if old: 

2150 raise _refusal("add()", old) 

2151 if kind is None: 

2152 raise TypeError("add() missing required keyword argument " 

2153 "'kind', e.g. add('leed', kind='credential')") 

2154 edit = _make_edit("add", words, kind, properties) 

2155 added = dataclasses.replace(self, _edits=self._edits + (edit,)) 

2156 ri = _index_module() 

2157 if ri.CATEGORY[edit.kind] == "title": # type: ignore[index] 

2158 before = self._pool() 

2159 for w in edit.words: 

2160 if (w in added._sets.titles_ambiguous 

2161 and not _updates(before.get(w, ()), edit)): 

2162 _warn_at_caller( 

2163 f"{w!r} holds a given reading, so it reads as a " 

2164 f"title only inside a title phrase (rules/H9) and " 

2165 f"this add reads no name differently elsewhere; to " 

2166 f"read it as a title everywhere, write " 

2167 f".remove({w!r}, kind='given')") 

2168 return added 

2169 

2170 def remove(self, *words: str, kind: str | None = None, 

2171 every: bool = False, **removed: object) -> Lexicon: 

2172 """This lexicon without each word's readings of `kind`: a kind, 

2173 or a category, matching every kind of it (``kind="title"`` takes 

2174 out ``dean``'s ``office`` reading, ``kind="name"`` its ``given`` 

2175 and ``family`` ones), or None, taking out every reading, so the 

2176 word leaves the vocabulary. With ``every=True``, no words and a 

2177 `kind`, every word's readings of it go (``remove(kind="title", 

2178 every=True)``), those a later choice of languages turns on 

2179 included; a call with no words and no `every` raises, so an 

2180 empty ``remove(*words, kind=...)`` never takes out a whole kind. 

2181 Removing a reading the word does not hold changes nothing now, 

2182 and is kept: a later choice of languages may turn that reading 

2183 on.""" 

2184 if removed: 

2185 raise _refusal("remove()", removed) 

2186 if not isinstance(every, bool): 

2187 raise TypeError(f"remove() takes every as True or False, got " 

2188 f"{every!r}") 

2189 if every and words: 

2190 raise TypeError( 

2191 f"remove() takes words or every=True, not both: every=True " 

2192 f"takes out every word's readings of the kind; got " 

2193 f"{len(words)} word(s)") 

2194 if every and kind is None: 

2195 raise TypeError( 

2196 "remove(every=True) takes a kind, e.g. remove(kind='title', " 

2197 "every=True); to remove everything, start from " 

2198 "Lexicon.empty()") 

2199 if not every and not words: 

2200 raise TypeError( 

2201 "remove() takes at least one word, e.g. remove('dean', " 

2202 "kind='title'), or every=True with a kind to take out every " 

2203 "word's readings of it, e.g. remove(kind='title', " 

2204 "every=True)") 

2205 edit = _make_edit("remove", words, kind, ()) 

2206 return dataclasses.replace(self, _edits=self._edits + (edit,)) 

2207 

2208 # -- private helpers -------------------------------------------------- 

2209 

2210 def _pool(self) -> Pool: 

2211 return self._files.pool() 

2212 

2213 def _headwords(self, word: str, group: str, *, 

2214 period: bool = True) -> frozenset[str]: 

2215 """Every word reachable from `word`, normalized, through the 

2216 `same_as` links of `group` (`given`, `family`, `title` or 

2217 `suffix`, the group of the readings' kinds), the word itself 

2218 included: `Wm.` among given names is {`wm`, `william`}, and `sr` 

2219 among titles {`sr`, `señor`, `senhor`}. A word's readings of 

2220 another kind are another group's (`ma` is María written short 

2221 among given names and only itself among surnames), and a word 

2222 with no listed spelling is itself alone. A loop, which a 

2223 caller's edits can write where the build refuses it in the 

2224 files, yields every word in it. The name match reads two words 

2225 as listed spellings of each other where their sets meet. `period` 

2226 says whether the word was written with its trailing full stop, 

2227 which normalizing drops: a word that is a name of its own in 

2228 `group` as well as a spelling (`jo`, José written short and the 

2229 given name Jo) reaches its links only where it was, and is itself 

2230 alone where it was not.""" 

2231 links = self._same_as.get(group) 

2232 if links is None: 

2233 raise ValueError(f"unknown same_as group {group!r}; the groups " 

2234 f"are {', '.join(sorted(_SAME_AS_GROUPS))}") 

2235 start = _normalize(word) 

2236 if not period and start in self._same_as_named[group]: 

2237 return frozenset({start}) 

2238 seen = {start} 

2239 todo = [start] 

2240 while todo: 

2241 for head in links.get(todo.pop(), ()): 

2242 if head not in seen: 

2243 seen.add(head) 

2244 todo.append(head) 

2245 return frozenset(seen) 

2246 

2247 def _familiar_forms(self, word: str) -> frozenset[str]: 

2248 """The given names `word`, normalized, is paired with directly 

2249 as a familiar form, in either direction (`max` -> {`maksim`, 

2250 `maxim`, `maximilian`}, `maximilian` -> {`max`}), empty for a 

2251 word in no pair. Never chained: two longer names paired with 

2252 one shorter one (`maximilian`, `maxim`) do not reach each 

2253 other. Read by the name match alone, never by a parse.""" 

2254 return self._familiar.get(_normalize(word), frozenset()) 

2255 

2256 def _deltas_from(self, other: Lexicon) -> tuple[ 

2257 list[tuple[str, int, int]], list[tuple[str, int, int]]]: 

2258 ri = _index_module() 

2259 mine, theirs = _keyed(self._pool()), _keyed(other._pool()) 

2260 kinds = [] 

2261 for kind in ri.CATEGORY: 

2262 a = sum(1 for key in mine - theirs if key[1] == kind) 

2263 r = sum(1 for key in theirs - mine if key[1] == kind) 

2264 if a or r: 

2265 kinds.append((kind, a, r)) 

2266 lists = [] 

2267 for name in _LIST_FIELDS + ("capitalization_exceptions",): 

2268 m, t = set(getattr(self, name)), set(getattr(other, name)) 

2269 if m != t: 

2270 lists.append((name, len(m - t), len(t - m))) 

2271 return kinds, lists 

2272 

2273 

2274def _keyed(pool: Pool) -> set[tuple[str, str, _Props]]: 

2275 """The readings on in `pool`, each once, as the repr counts them: 

2276 by word, kind and properties, whatever file holds them.""" 

2277 return {(r.text, r.kind, _clean(r.props)) 

2278 for rs in pool.values() for r in rs if r.on_by_default} 

2279 

2280 

2281def _updates(held: Iterable[Any], edit: _Edit) -> bool: 

2282 """Whether `edit` only updates the properties of a reading of its 

2283 kind the word holds: the title warning's exemption (decisions/ 

2284 vocabulary-model, 2026-11-01 #754, the title warning).""" 

2285 return any(r.kind == edit.kind 

2286 and _clean(_merged(r.props, edit.props)) != _clean(r.props) 

2287 for r in held) 

2288 

2289 

2290@functools.cache 

2291def _default_lexicon() -> Lexicon: 

2292 # nameparser/config/_compiled.py, compiled from nameparser/vocab/, is 

2293 # the single source of the default vocabulary: its sets are read from 

2294 # it, never compiled from the index here (_compiled_for), so 

2295 # Lexicon.default() loads no index. 

2296 from nameparser.config._compiled import CAPITALIZATION_EXCEPTIONS 

2297 from nameparser.config._ko_surnames import KOREAN_SURNAMES 

2298 

2299 # every vocabulary constant is a frozenset since #293, and this 

2300 # cache reading them ONCE is the reason they are frozen. 

2301 return Lexicon( 

2302 surnames=KOREAN_SURNAMES, 

2303 # pass canonical pair-tuples so this strictly-typed call site never 

2304 # feeds a Mapping to the tuple-annotated field; __init__ still 

2305 # tolerates a Mapping at runtime for interactive use 

2306 capitalization_exceptions=tuple(sorted(CAPITALIZATION_EXCEPTIONS.items())), 

2307 _base="default", 

2308 )