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

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

317 statements  

1"""The reading index, and the compiler that folds readings into sets. 

2 

3The default vocabulary is written per word in nameparser/vocab/*.toml, 

4each word listing its READINGS (what it can be read as, keyed by kind) 

5in the file of the language whose names it occurs in 

6(decisions/vocabulary-model). tools/vocab/build.py checks the files and 

7writes two modules from them, each generated and committed: 

8 

9* nameparser/config/_index.py, the READING INDEX: for each word, one 

10 row per reading, ``(tag, kind, properties)``, the tag being its file's 

11 BCP 47 tag and the properties the ones the compiler reads. It is what 

12 a set has forgotten, which file each word came from. 

13* nameparser/config/_compiled.py, the sets the parser reads, which is 

14 :func:`compile_readings` run over the index. 

15 

16This module is the compiler both the build and a choice of languages 

17(phase 3b, :func:`choose`) run, so there is one implementation of what a reading 

18compiles to; tests/v2/test_reading_index.py holds that compiling the 

19index reproduces _compiled.py exactly. It is also the one way to read 

20the index at run time (:func:`readings_of`, :func:`index`), and the 

21reader every parse calls for ``Reading.languages`` 

22(:func:`languages_of`), so the first parse in a process loads it. 

23 

24It imports nothing internal at module level, and the index only inside 

25:func:`index`: the build loads this file by path, as it loads 

26_lexicon.py, so that a generated module left broken cannot stop the 

27tool that rewrites it. 

28""" 

29from __future__ import annotations 

30 

31import functools 

32import unicodedata 

33from collections.abc import Iterable, Mapping 

34from dataclasses import dataclass 

35from types import MappingProxyType 

36from typing import Any, TypedDict 

37 

38#: kind -> category. The category decides which compiled field a reading 

39#: feeds. For title, suffix, particle and conjunction, the category's own 

40#: name is also a kind (`title = {}`: a title not yet classified), so 

41#: classifying a word later moves no set. bound_given's one kind shares 

42#: its category's name; the relation (birth_marker) and name (given, 

43#: family) categories have no generic kind. Kind 

44#: names are unique across categories; a fine kind arrives with its 

45#: category's classification, in a core PR, as one row here. The 

46#: familiar category's one kind, `familiar` (a short given name standing 

47#: for longer ones, `max` of `maximilian`), compiles into FAMILIAR alone, 

48#: which only the name match reads: no parse reads it (decisions/ 

49#: name-matching, 2026-11-08 FAMILIAR FORMS). 

50CATEGORY: dict[str, str] = { 

51 "title": "title", 

52 "salutation": "title", 

53 "style": "title", 

54 "academic": "title", 

55 "religious": "title", 

56 "nobility": "title", 

57 "military": "title", 

58 "office": "title", 

59 "occupation": "title", 

60 "kinship": "title", 

61 "modifier": "title", 

62 "suffix": "suffix", 

63 "generation": "suffix", 

64 "agnomen": "suffix", 

65 "credential": "suffix", 

66 "honorific": "suffix", 

67 "professional_honorific": "suffix", 

68 "status": "suffix", 

69 "courtesy": "suffix", 

70 "particle": "particle", 

71 "preposition": "particle", 

72 "article": "particle", 

73 "patronymic": "particle", 

74 "teknonymic": "particle", 

75 "clan": "particle", 

76 "rank": "particle", 

77 "saint": "particle", 

78 "alias": "particle", 

79 "conjunction": "conjunction", 

80 "bound_given": "bound_given", 

81 "birth_marker": "relation", 

82 "given": "name", 

83 "family": "name", 

84 "familiar": "familiar", 

85} 

86 

87#: The categories a choice neither prefers nor drops by (`choose`): a 

88#: name reading keeps a gate on and settles nothing, and a familiar 

89#: reading moves no parse at all, so neither competes with another 

90#: file's reading of the word. 

91UNPREFERRED = frozenset({"name", "familiar"}) 

92 

93#: Behavioral properties per category: each compiles into a field, and 

94#: its allowed values are exactly what today's pipeline does. A name 

95#: reading's are `default` (phase 3a), `cased`, the word's case mask 

96#: (the shipped ones on `family` readings, #1014), `shared`, which 

97#: rules/P10, O6 and O7 read, and `after_particle`, which P10 and O6 read 

98#: (#704): its other keys are evidence. A particle reading's 

99#: `capital_opening` is rules/R12's: the particle takes a capital where 

100#: it opens the family name, the Armenian `Ter` and `Der`. 

101BEHAVIORAL: dict[str, frozenset[str]] = { 

102 "title": frozenset({"abbreviation", "addresses", "after_given", 

103 "attached", "attaches", "cased", "default", 

104 "dotted"}), 

105 "suffix": frozenset({"acronym", "attached", "cased", "default", 

106 "title_case_is_name"}), 

107 "particle": frozenset({"capital_opening", "cased", "default"}), 

108 "conjunction": frozenset({"may_be_initial", "cased", "default"}), 

109 "bound_given": frozenset({"completed_by", "cased", "default"}), 

110 "relation": frozenset({"cased", "default"}), 

111 "name": frozenset({"after_particle", "cased", "default", "shared"}), 

112 "familiar": frozenset({"default"}), 

113} 

114 

115#: The suffix word kinds a name reading gates (rules/S11): the third 

116#: derived marker reads these, and a name reading beside another word 

117#: kind marks nothing, no gate reading that clash yet. A credential is 

118#: here for one written as a word (`acronym = false`), `kc`, `pc` and 

119#: `dl`, whose dotted spellings must stay the paired initials (#965); a 

120#: credential acronym's clash is the second marker's. 

121GATED_WORDS = frozenset({"generation", "agnomen", "credential"}) 

122 

123#: The suffix word kinds rules/R8's `honorifics` view holds: a form of 

124#: address written after the name (`さん`, `님`, `先生`), which CLDR 

125#: files as a title; an office written there (`議員`) is a credential 

126#: (#326). 

127HONORIFIC_KINDS = frozenset({"honorific", "professional_honorific"}) 

128 

129#: The particle kinds written with a capital, a saint's name (`St Clair`, 

130#: `San Martín`) and a German rank inside the surname (`Freiherr von 

131#: Richthofen`): rules/R4's case repair does not lowercase them (#1069). 

132CAPITALIZED_PARTICLE_KINDS = frozenset({"saint", "rank"}) 

133 

134 

135#: The group a reading's `same_as` is read in, by the reading's 

136#: category, a name reading's being its kind (`given`, `family`): the 

137#: roles of a name each read one group (decisions/name-matching, 

138#: 2026-11-08), so a given name's listed spelling is not a surname's 

139#: (`ma`, María written short, beside the surname Ma). A bound given 

140#: name stands in the given name and a particle in the surname, so 

141#: theirs are read there; a conjunction's and a birth marker's stand in 

142#: no role a match compares, and compile to no group. 

143SAME_AS_GROUPS: Mapping[str, str] = MappingProxyType({ 

144 "given": "given", 

145 "bound_given": "given", 

146 "family": "family", 

147 "particle": "family", 

148 "title": "title", 

149 "suffix": "suffix", 

150}) 

151 

152 

153def same_as_group(kind: str) -> str | None: 

154 """The `SAME_AS` group a `kind` reading's `same_as` compiles into, 

155 or None where it compiles into none.""" 

156 category = CATEGORY[kind] 

157 return SAME_AS_GROUPS.get(kind if category == "name" else category) 

158 

159 

160def indexed(kind: str) -> frozenset[str]: 

161 """The properties of a `kind` reading the index keeps: its 

162 category's behavioral ones, and on every kind but `familiar` 

163 `same_as`, the word a spelling stands for, which compiles into 

164 SAME_AS (in the group of `same_as_group`) and on a `given` reading 

165 also into GIVEN_NAME_ABBREVIATIONS (#789); on a `familiar` reading 

166 `short_for`, the longer given names the word stands for, which 

167 compiles into FAMILIAR. Everything else a reading carries is 

168 evidence, compiled to nothing.""" 

169 if CATEGORY[kind] == "familiar": 

170 return BEHAVIORAL["familiar"] | {"short_for"} 

171 return BEHAVIORAL[CATEGORY[kind]] | {"same_as"} 

172 

173 

174class ReadingProperties: 

175 """The unwritten values of the properties whose default is not 

176 false, defined once for every reading type that has a `kind` and 

177 `props`: the build's Reading, which holds a reading as its file 

178 wrote it, and :class:`IndexReading`, which holds it as the index 

179 does.""" 

180 

181 __slots__ = () 

182 text: str 

183 kind: str 

184 props: Mapping[str, Any] 

185 

186 @property 

187 def category(self) -> str: 

188 return CATEGORY[self.kind] 

189 

190 @property 

191 def acronym(self) -> bool: 

192 """Matched with its periods removed: unwritten, true only for a 

193 credential.""" 

194 return bool(self.props.get("acronym", self.kind == "credential")) 

195 

196 @property 

197 def on_by_default(self) -> bool: 

198 """Compiled into the default vocabulary: unwritten, true. A 

199 reading written `default = false` is recorded and checked as a 

200 reading but compiles to nothing, a name reading included (3a), 

201 for a pack to turn on in phase 3 (decisions/vocabulary-model).""" 

202 return self.props.get("default", True) is not False 

203 

204 def flag(self, prop: str) -> bool: 

205 """A `may_be_*` flag, `after_given`, `abbreviation`, 

206 `title_case_is_name`, `shared`, `after_particle`, 

207 `capital_opening` or `dotted`: unwritten, false.""" 

208 return bool(self.props.get(prop, False)) 

209 

210 @property 

211 def addresses(self) -> object: 

212 """Which name a title addresses: unwritten, the family name. An 

213 object, not a str, because the build reads it before its check 

214 3 has refused a value that is not one of the allowed strings.""" 

215 return self.props.get("addresses", "family") 

216 

217 

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

219class IndexReading(ReadingProperties): 

220 """One row of the reading index. 

221 

222 ``text`` is the word, normalized as the vocabulary stores it; 

223 ``tag`` the BCP 47 tag of the file holding the reading (``"en"``, 

224 ``"pt-BR"``, ``"mul"``, ``"und"``); ``kind`` its vocabulary kind, 

225 a key of :data:`CATEGORY` (``"credential"``, ``"preposition"``, 

226 ``"given"``), finer than :class:`nameparser.ReadingKind`; 

227 ``props`` the properties :func:`indexed` keeps, as written, read 

228 through the accessors above for their unwritten values. A word in 

229 several files has a row per file, and a reading written 

230 ``default = false`` has its row too, with 

231 ``on_by_default`` false. 

232 

233 A lookup result, not a value to keep: its ``props`` is a read-only 

234 view, so an IndexReading neither hashes nor pickles. What a parse 

235 keeps of it is its tag, in ``Reading.languages`` 

236 (:func:`languages_of`).""" 

237 

238 text: str 

239 tag: str 

240 kind: str 

241 props: Mapping[str, Any] 

242 

243 

244#: A row as nameparser/config/_index.py writes it, under its word: 

245#: (tag, kind, properties), the properties as sorted (key, value) pairs, 

246#: a list value as a sorted tuple and the `attached` table as its side, 

247#: so the whole index is one constant. 

248Row = tuple[str, str, tuple[tuple[str, Any], ...]] 

249#: The index as written: (text, rows) per word, sorted by text, its 

250#: rows by tag and then kind. 

251Index = tuple[tuple[str, tuple[Row, ...]], ...] 

252 

253 

254def to_row(r: IndexReading) -> Row: 

255 """`r` as the index writes it under its word.""" 

256 pairs = [] 

257 for key in sorted(r.props): 

258 value = r.props[key] 

259 if key == "attached": 

260 value = value["side"] if isinstance(value, Mapping) else value 

261 elif isinstance(value, (list, tuple, set, frozenset)): 

262 value = tuple(sorted(value)) 

263 pairs.append((key, value)) 

264 return (r.tag, r.kind, tuple(pairs)) 

265 

266 

267def from_row(text: str, row: Row) -> IndexReading: 

268 tag, kind, pairs = row 

269 return IndexReading(text, tag, kind, MappingProxyType(dict(pairs))) 

270 

271 

272@functools.cache 

273def _by_text() -> Mapping[str, tuple[Row, ...]]: 

274 # the index is written grouped by word, so this is one dict() call 

275 # in C, and an IndexReading is built only for a word looked up: 

276 # building all of them costs more than importing the index 

277 # (decisions/vocabulary-model, 2026-10-29 3a) 

278 from nameparser.config._index import READINGS 

279 return MappingProxyType(dict(READINGS)) 

280 

281 

282@functools.cache 

283def index() -> tuple[IndexReading, ...]: 

284 """Every row of the shipped reading index, in its order (by text, 

285 then tag, then kind), for a compile over the whole pool. Built on 

286 first call and kept: the index describes the shipped files, which 

287 do not change in a process.""" 

288 return tuple(from_row(text, row) 

289 for text, rows in _by_text().items() for row in rows) 

290 

291 

292def readings_of(text: str) -> tuple[IndexReading, ...]: 

293 """Every reading the shipped files hold for `text`, on by default 

294 or not, one per file and kind, in index order; empty for a word no 

295 file holds. 

296 

297 `text` is looked up as the vocabulary stores it (lowercase, NFC, 

298 edge periods removed; _lexicon._normalize), and a phrase whole 

299 (``"z domu"``). A reading the parse took from a caller's own 

300 vocabulary, from a shape or from position has no row here, which is 

301 what an empty ``Reading.languages`` says; the parse reads the tags 

302 through :func:`languages_of`, which builds no IndexReading.""" 

303 return tuple(from_row(text, row) for row in _by_text().get(text, ())) 

304 

305 

306#: The tags no choice names: mul.toml's readings are every language's 

307#: and und.toml's no known language's, so neither is a language to 

308#: choose. A plain choice never removes a reading of either (choose); a 

309#: strict one, which drops every file not chosen, keeps mul.toml's all 

310#: the same, as it keeps native-script words' readings, and drops 

311#: und.toml's (:func:`strict_pool`; decisions/vocabulary-model, 

312#: 2026-11-02 #1005). 

313UNCHOOSABLE = frozenset({"mul", "und"}) 

314 

315 

316@functools.cache 

317def tags() -> frozenset[str]: 

318 """The tags of the shipped files that hold a reading a choice can 

319 change: any but a familiar one. A plain choice never drops a 

320 familiar reading, which no parse reads, so a file holding only 

321 those (`hu.toml`, `mt.toml`, `sq.toml`) would change nothing as a 

322 plain choice, and is refused as one holding no reading 

323 (decisions/name-matching, 2026-11-08 FAMILIAR FORMS).""" 

324 return frozenset(row[0] for rows in _by_text().values() for row in rows 

325 if CATEGORY[row[1]] != "familiar") 

326 

327 

328@functools.cache 

329def files() -> frozenset[str]: 

330 """The tags of every shipped vocabulary file, one holding no 

331 reading (only exclusions) included: :func:`tags` and the files a 

332 strict choice may name, which a plain one may not.""" 

333 from nameparser.config._index import FILES 

334 return frozenset(FILES) 

335 

336 

337def selected(languages: Iterable[str], *, 

338 strict: bool = False) -> frozenset[str]: 

339 """The file tags a choice of `languages` selects: each chosen tag's 

340 own and its ancestors', never its descendants' (``pt-BR`` selects 

341 ``pt-BR`` and ``pt``; ``pt`` only ``pt``), matched without regard to 

342 case, as BCP 47 tags are. No macrolanguage table is read: ``ms`` 

343 does not select ``id``. 

344 

345 A plain choice selects among the files that hold a reading, since a 

346 file holding none would change nothing; a `strict` one among every 

347 file, since it drops whatever is not chosen and so is never inert 

348 (``hu``, whose file holds only exclusions and familiar forms). 

349 

350 Raises ValueError for ``mul`` or ``und``, and for a tag that selects 

351 no file at all, naming the tags that do.""" 

352 held = (files() if strict else tags()) - UNCHOOSABLE 

353 by_fold = {t.casefold(): t for t in held} 

354 out: set[str] = set() 

355 for tag in languages: 

356 if tag.casefold() in UNCHOOSABLE: 

357 raise ValueError( 

358 f"{tag!r} is not a language to choose: mul.toml's readings " 

359 f"are every language's and und.toml's no known language's; " 

360 f"every choice, plain or strict, keeps mul.toml's, a plain " 

361 f"one keeps und.toml's too and a strict one drops " 

362 f"und.toml's. Drop it") 

363 found = {by_fold[a] for a in _ancestors(tag) if a in by_fold} 

364 if not found: 

365 empty = sorted(t for t in files() - tags() - UNCHOOSABLE 

366 if t.casefold() in _ancestors(tag)) 

367 if empty: 

368 raise ValueError( 

369 f"{empty[0]}.toml holds no reading a parse reads yet, " 

370 f"so a plain choice of {tag!r} would change nothing; a " 

371 f"strict one (strict=True) accepts it") 

372 raise ValueError( 

373 f"no vocabulary file holds the language {tag!r} or one it " 

374 f"belongs to; the languages that have one: " 

375 f"{', '.join(sorted(by_fold.values()))}") 

376 out |= found 

377 return frozenset(out) 

378 

379 

380def _ancestors(tag: str) -> list[str]: 

381 """`tag` and the tags it belongs to, casefolded, longest first.""" 

382 parts = tag.split("-") 

383 return ["-".join(parts[:i]).casefold() for i in range(len(parts), 0, -1)] 

384 

385 

386def choose(readings: Iterable[IndexReading], 

387 chosen: frozenset[str]) -> list[IndexReading]: 

388 """The readings a choice of the file tags `chosen` compiles, each 

389 as :func:`compile_readings` is to read it (decisions/vocabulary-model, 

390 2026-10-27 #753, items 2 and 7): 

391 

392 * **turning on**: a chosen file's ``default = false`` readings join 

393 the readings on by default; 

394 * **prefer**: on a word where a chosen file holds a vocabulary 

395 reading (any category but a name), the other files' vocabulary 

396 readings leave, save mul.toml's, which are every language's, and 

397 und.toml's, whose language is not known to be another's. Name 

398 readings never leave and never settle anything (decision 15): 

399 they keep the gate on; nor do familiar readings, which no parse 

400 reads (:data:`UNPREFERRED`); 

401 * **the shape clash**: a chosen file's conjunction reading drops 

402 ``may_be_initial``, the clash it records being with the initial 

403 the letter spells, a shape reading, which a preference outranks 

404 (decision 20). 

405 

406 With nothing chosen this is the readings on by default, unchanged, 

407 so the default is the compile of an empty choice.""" 

408 if not chosen: 

409 return [r for r in readings if r.on_by_default] 

410 live = [r for r in readings if r.on_by_default or r.tag in chosen] 

411 preferred = {r.text for r in live 

412 if r.tag in chosen and r.category not in UNPREFERRED} 

413 out = [] 

414 for r in live: 

415 if (r.text in preferred and r.category not in UNPREFERRED 

416 and r.tag not in chosen and r.tag not in UNCHOOSABLE): 

417 continue 

418 if r.tag in chosen and ("default" in r.props 

419 or (r.category == "conjunction" 

420 and "may_be_initial" in r.props)): 

421 props = {k: v for k, v in r.props.items() 

422 if k not in ("default", "may_be_initial")} 

423 r = IndexReading(r.text, r.tag, r.kind, MappingProxyType(props)) 

424 out.append(r) 

425 return out 

426 

427 

428def is_native_script(text: str) -> bool: 

429 """Whether a strict choice keeps `text` whatever its file: it holds 

430 a letter and no Latin letter (the CJK honorifics, the Cyrillic and 

431 Devanagari titles), so it selects itself by script, which is read 

432 from the word as the parse always reads it. A word with no letter 

433 (``&``, ``2``) is not native-script.""" 

434 letters = [c for c in text if c.isalpha()] 

435 return bool(letters) and not any( 

436 "LATIN" in unicodedata.name(c, "") for c in letters) 

437 

438 

439def strict_pool(chosen: frozenset[str]) -> list[IndexReading]: 

440 """The readings a strict choice of the file tags `chosen` compiles 

441 from, before :func:`choose`: the chosen files' and mul.toml's 

442 readings and every reading of a native-script word 

443 (:func:`is_native_script`) whatever file holds it, name readings 

444 included; no other file's, und.toml's among them 

445 (file-strict; decisions/vocabulary-model, 2026-10-30, and 

446 2026-11-02 #1005 for mul.toml).""" 

447 return strict_kept(index(), chosen) 

448 

449 

450def strict_kept(readings: Iterable[IndexReading], 

451 chosen: frozenset[str]) -> list[IndexReading]: 

452 """The readings of `readings` a strict choice of the file tags 

453 `chosen` keeps: the chosen files', mul.toml's, which are every 

454 language's, and native-script ones.""" 

455 return [r for r in readings 

456 if r.tag in chosen or r.tag == "mul" or is_native_script(r.text)] 

457 

458 

459def chosen_readings(readings: Iterable[IndexReading], chosen: frozenset[str], 

460 strict: bool) -> list[IndexReading]: 

461 """`readings` under the choice of the file tags `chosen`: under 

462 `strict` only the chosen files', mul.toml's and native-script ones (as 

463 :func:`strict_pool` keeps them), then :func:`choose`. What 

464 :func:`compile_choice` compiles, and what a `Lexicon` under a 

465 choice reads a word's readings by.""" 

466 return choose(strict_kept(readings, chosen) if strict else readings, 

467 chosen) 

468 

469 

470@functools.cache 

471def compile_choice(chosen: frozenset[str], strict: bool = False) -> Compiled: 

472 """The constants the shipped index compiles to under a choice of 

473 the file tags `chosen` (:func:`selected`'s result), kept per choice: 

474 a compile costs milliseconds, paid once per choice and only by a 

475 caller who makes one. An empty plain choice compiles the default; a 

476 `strict` one compiles from :func:`strict_pool` alone.""" 

477 return compile_readings(chosen_readings(index(), chosen, strict)) 

478 

479 

480def strict_ungated(chosen: frozenset[str]) -> frozenset[str]: 

481 """The words a strict choice of the file tags `chosen` ungates: a 

482 chosen file or mul.toml holds the word's vocabulary reading and 

483 only a dropped file the name reading gating it, so under strict a possibly-given 

484 particle is never-given, a title only inside a phrase is a title, 

485 and a suffix gated by the count is not. Compared with the PLAIN 

486 choice of the same tags, never with the default's markers, which 

487 miss a reading the choice turns on (decisions/vocabulary-model, 

488 2026-10-30 #753, file-strict).""" 

489 plain, strict = compile_choice(chosen), compile_choice(chosen, True) 

490 out = ((plain["PARTICLES"] - plain["NON_GIVEN_NAME_PARTICLES"]) 

491 & strict["NON_GIVEN_NAME_PARTICLES"]) 

492 out |= plain["TITLES_AMBIGUOUS"] & strict["TITLES"] 

493 out |= plain["SUFFIX_ACRONYMS_AMBIGUOUS"] & ( 

494 strict["SUFFIX_ACRONYMS"] - strict["SUFFIX_ACRONYMS_AMBIGUOUS"]) 

495 out |= plain["SUFFIX_WORDS_AMBIGUOUS"] & ( 

496 strict["SUFFIX_WORDS"] - strict["SUFFIX_WORDS_AMBIGUOUS"]) 

497 return frozenset(out) 

498 

499 

500def changed_words(chosen: frozenset[str]) -> frozenset[str]: 

501 """The words whose compiled readings the choice `chosen` changes: 

502 every entry that joins or leaves a constant, a title phrase's two 

503 words among them, and every word whose case mask changes. This is a 

504 vocabulary choice's deviation surface, derived from the index rather 

505 than declared by hand (the RFC's "Measuring a pack"): a name holding 

506 none of these words parses under the choice as under the default.""" 

507 if not chosen: 

508 return frozenset() 

509 default, choice = compile_choice(frozenset()), compile_choice(chosen) 

510 words: set[str] = set() 

511 for key in default: 

512 if key in ("SAME_AS", "SAME_AS_NAMED", "FAMILIAR"): 

513 # a word's listed spelling moves no word between sets, so it 

514 # changes no parse: a choice's `sr` of `senhor`, not `señor`, 

515 # and whether the match reads the link written bare; nor does 

516 # a familiar form, which only the match reads 

517 continue 

518 a: Any = default[key] # type: ignore[literal-required] 

519 b: Any = choice[key] # type: ignore[literal-required] 

520 if isinstance(a, dict): 

521 words |= {k for k in a.keys() | b.keys() if a.get(k) != b.get(k)} 

522 else: 

523 words |= a ^ b 

524 return frozenset(w for entry in words for w in (entry, *entry.split())) 

525 

526 

527def languages_of(text: str, kinds: frozenset[str]) -> tuple[str, ...]: 

528 """The tags of the files holding a reading of `text`, on by 

529 default, of one of `kinds` (keys of :data:`CATEGORY`), each once, 

530 in index order; empty for a word no file holds as one of them. 

531 `text` is normalized as :func:`readings_of` takes it. What 

532 ``Reading.languages`` holds under the default lexicon, which reads 

533 it through ``Lexicon._files`` (decisions/vocabulary-model, 

534 2026-10-29; 2026-11-02, 4i-d); its first call in a process loads 

535 the index.""" 

536 out: list[str] = [] 

537 for tag, kind, pairs in _by_text().get(text, ()): 

538 if (kind in kinds and tag not in out 

539 and ("default", False) not in pairs): 

540 out.append(tag) 

541 return tuple(out) 

542 

543 

544def suffix_kind_of(text: str) -> str | None: 

545 """The kind of `text`'s suffix reading (a key of :data:`CATEGORY` 

546 whose category is ``suffix``), on by default or not; None for a 

547 word no file holds as a suffix. A word has one suffix kind in every 

548 file (the build's check 4), so the first row says it. `text` is 

549 normalized as :func:`readings_of` takes it. The shipped files' 

550 answer only: since 3.0's 4i-b the parse reads a suffix word's kind 

551 from its own `Lexicon`, which compiles it (GENERATION_WORDS, 

552 CREDENTIAL_WORDS, HONORIFIC_WORDS), and this agrees with the default's.""" 

553 for _tag, kind, _pairs in _by_text().get(text, ()): 

554 if CATEGORY[kind] == "suffix": 

555 return kind 

556 return None 

557 

558 

559def holds_kind(text: str, kind: str) -> bool: 

560 """Whether a file holds `text` in a reading of `kind` (a key of 

561 :data:`CATEGORY`), on by default or not. `text` is normalized as 

562 :func:`readings_of` takes it. The shipped files' answer only: since 

563 3.0's 4i-b the parse reads a particle's patronymic kind from its 

564 own `Lexicon` (PATRONYMIC_PARTICLES).""" 

565 return any(k == kind for _tag, k, _pairs in _by_text().get(text, ())) 

566 

567 

568class CompileError(ValueError): 

569 """The readings cannot compile: a fact about the data, which the 

570 build reports as a vocabulary error (exit 2) where any other 

571 exception from the compiler is an internal one (exit 3).""" 

572 

573 

574class Compiled(TypedDict): 

575 """What :func:`compile_readings` returns: one key per constant, in 

576 the order nameparser/config/_compiled.py defines them, typed as it 

577 declares them.""" 

578 GIVEN_NAME_TITLES: frozenset[str] 

579 TITLES: frozenset[str] 

580 AFTER_GIVEN_TITLES: frozenset[str] 

581 TITLES_AMBIGUOUS: frozenset[str] 

582 TITLE_MODIFIERS: frozenset[str] 

583 TITLE_STYLES: frozenset[str] 

584 TITLE_PHRASES: frozenset[str] 

585 NON_GIVEN_NAME_PARTICLES: frozenset[str] 

586 PARTICLES: frozenset[str] 

587 CONJUNCTIONS: frozenset[str] 

588 CONJUNCTIONS_AMBIGUOUS: frozenset[str] 

589 BOUND_GIVEN_NAMES: frozenset[str] 

590 GIVEN_NAME_ABBREVIATIONS: frozenset[str] 

591 MAIDEN_MARKERS: frozenset[str] 

592 SUFFIX_WORDS: frozenset[str] 

593 GLUED_HONORIFICS: frozenset[str] 

594 SUFFIX_WORDS_AMBIGUOUS: frozenset[str] 

595 AGNOMENS: frozenset[str] 

596 SUFFIX_ACRONYMS_AMBIGUOUS: frozenset[str] 

597 SUFFIX_ACRONYMS: frozenset[str] 

598 CAPITALIZATION_EXCEPTIONS: dict[str, str] 

599 GENERATION_WORDS: frozenset[str] 

600 CREDENTIAL_WORDS: frozenset[str] 

601 HONORIFIC_WORDS: frozenset[str] 

602 PATRONYMIC_PARTICLES: frozenset[str] 

603 TITLE_ABBREVIATIONS: frozenset[str] 

604 TITLE_CASE_NAME_WORDS: frozenset[str] 

605 SHARED_NAMES: frozenset[str] 

606 PARTICLE_GIVEN_NAMES: frozenset[str] 

607 SAINT_PARTICLES: frozenset[str] 

608 CAPITALIZED_PARTICLES: frozenset[str] 

609 CAPITAL_OPENING_PARTICLES: frozenset[str] 

610 DOTTED_TITLES: frozenset[str] 

611 GLUED_TITLES: frozenset[str] 

612 BOUND_PHRASES: frozenset[str] 

613 SAME_AS: dict[str, dict[str, tuple[str, ...]]] 

614 SAME_AS_NAMED: dict[str, frozenset[str]] 

615 FAMILIAR: dict[str, tuple[str, ...]] 

616 

617 

618def compile_readings(readings: Iterable[ReadingProperties]) -> Compiled: 

619 """Fold the readings on by default into the default vocabulary's 

620 constants. The readings are taken as already checked (the build's 

621 checks run before it calls this); each needs ``text``, ``kind`` 

622 and ``props``. 

623 

624 Raises :class:`CompileError` on a post-nominal that is both an 

625 acronym and a word, a backstop the build's checks 4 and 5 always 

626 reach first. Any other exception is a bug in this function.""" 

627 texts = [(r, r.text) for r in readings if r.on_by_default] 

628 # the derived markers (the build's check 11): a clash with a name is 

629 # a word's name reading beside the reading it clashes with, and a 

630 # name reading written `default = false` marks nothing in the 

631 # default, as it compiles to nothing (3a) 

632 named = {t for r, t in texts if r.category == "name"} 

633 given = {t for r, t in texts if r.kind == "given"} 

634 titles: set[str] = set() 

635 given_titles: set[str] = set() 

636 after_given: set[str] = set() 

637 written_short: set[str] = set() 

638 title_kinds: dict[str, set[str]] = {} 

639 attaches: dict[str, set[str]] = {} 

640 particles: set[str] = set() 

641 never_given: set[str] = set() 

642 conjunctions: set[str] = set() 

643 initial_conjunctions: set[str] = set() 

644 bound: set[str] = set() 

645 abbreviations: set[str] = set() 

646 markers: set[str] = set() 

647 words: set[str] = set() 

648 glued: set[str] = set() 

649 ambiguous_words: set[str] = set() 

650 agnomens: set[str] = set() 

651 ambiguous: set[str] = set() 

652 acronyms: set[str] = set() 

653 generations: set[str] = set() 

654 credential_words: set[str] = set() 

655 honorific_words: set[str] = set() 

656 patronymics: set[str] = set() 

657 saints: set[str] = set() 

658 capitalized: set[str] = set() 

659 capital_opening: set[str] = set() 

660 dotted: set[str] = set() 

661 glued_titles: set[str] = set() 

662 title_case_names: set[str] = set() 

663 shared: set[str] = set() 

664 after_particle: set[str] = set() 

665 bound_phrases: set[str] = set() 

666 cased: dict[str, str] = {} 

667 same_as: dict[str, dict[str, set[str]]] = { 

668 g: {} for g in SAME_AS_GROUPS.values()} 

669 # per name kind, the words a reading of it writes short (`same_as`) 

670 # and the words it names with none: a name of its own 

671 short_names: dict[str, set[str]] = {} 

672 own_names: dict[str, set[str]] = {} 

673 # a familiar form -> the given names it stands for, every file's 

674 familiar: dict[str, set[str]] = {} 

675 for r, t in texts: 

676 p = r.props 

677 if r.category == "familiar": 

678 familiar.setdefault(t, set()).update(p.get("short_for", ())) 

679 continue 

680 if "same_as" in p and (group := same_as_group(r.kind)) is not None: 

681 same_as[group].setdefault(t, set()).add(p["same_as"]) 

682 if r.category == "name": 

683 into = short_names if "same_as" in p else own_names 

684 into.setdefault(r.kind, set()).add(t) 

685 if "cased" in p: 

686 cased[t] = p["cased"] 

687 if r.category == "name" and r.flag("shared"): 

688 shared.add(t) 

689 if r.kind == "given" and r.flag("after_particle"): 

690 after_particle.add(t) 

691 if r.category == "title": 

692 titles.add(t) 

693 if r.addresses == "given": 

694 given_titles.add(t) 

695 if r.flag("after_given"): 

696 after_given.add(t) 

697 if r.flag("dotted"): 

698 dotted.add(t) 

699 if "attached" in p: 

700 # a title reading's side is "before", the build's check 

701 glued_titles.add(t) 

702 if r.flag("abbreviation"): 

703 written_short.add(t) 

704 title_kinds.setdefault(t, set()).add(r.kind) 

705 attaches.setdefault(t, set()).update(p.get("attaches", ())) 

706 elif r.category == "suffix": 

707 if r.acronym: 

708 acronyms.add(t) 

709 if t in named: 

710 ambiguous.add(t) 

711 else: 

712 words.add(t) 

713 if "attached" in p: 

714 glued.add(t) 

715 if t in named and r.kind in GATED_WORDS: 

716 ambiguous_words.add(t) 

717 if r.flag("title_case_is_name"): 

718 title_case_names.add(t) 

719 if r.kind == "agnomen": 

720 agnomens.add(t) 

721 elif r.kind == "generation": 

722 generations.add(t) 

723 elif r.kind == "credential": 

724 credential_words.add(t) 

725 elif r.kind in HONORIFIC_KINDS: 

726 honorific_words.add(t) 

727 elif r.category == "particle": 

728 particles.add(t) 

729 if r.kind == "patronymic": 

730 patronymics.add(t) 

731 elif r.kind == "saint": 

732 saints.add(t) 

733 if r.kind in CAPITALIZED_PARTICLE_KINDS: 

734 capitalized.add(t) 

735 if r.flag("capital_opening"): 

736 capital_opening.add(t) 

737 if t not in given: 

738 never_given.add(t) 

739 elif r.category == "conjunction": 

740 conjunctions.add(t) 

741 if r.flag("may_be_initial"): 

742 initial_conjunctions.add(t) 

743 elif r.category == "bound_given": 

744 bound.add(t) 

745 bound_phrases.update(f"{t} {w}" 

746 for w in p.get("completed_by", ())) 

747 elif r.category == "relation": 

748 markers.add(t) 

749 elif r.kind == "given" and "same_as" in p: 

750 abbreviations.add(t) 

751 contested = titles & given - particles 

752 out = Compiled( 

753 GIVEN_NAME_TITLES=frozenset(given_titles), 

754 # a title that may be a given name compiles into TITLES_AMBIGUOUS 

755 # INSTEAD: a word in TITLES is a title wherever it stands, which 

756 # is what a caller listing one there has always asked for 

757 TITLES=frozenset(titles - contested), 

758 AFTER_GIVEN_TITLES=frozenset(after_given), 

759 # the fourth derived marker (check 11): a title that may be a 

760 # given name, a title only inside a title phrase (rules/H9), and 

761 # so not in TITLES above. A 

762 # title that is also a particle ('st', 'freiherr') is left to the 

763 # particle rules, which weigh its given reading at the front 

764 # already (rules/P4) 

765 TITLES_AMBIGUOUS=frozenset(contested), 

766 # a word every title reading of which is a modifier: a title only 

767 # with a head title behind it in its run (decision 10, rules/H9) 

768 TITLE_MODIFIERS=frozenset( 

769 t for t, ks in title_kinds.items() if ks == {"modifier"}), 

770 TITLE_STYLES=frozenset( 

771 t for t, ks in title_kinds.items() if "style" in ks), 

772 # `attaches` resolved to the two-word phrases it licenses, the 

773 # word before and each title word of a kind it names 

774 TITLE_PHRASES=frozenset( 

775 f"{t} {h}" for t, kinds in attaches.items() if kinds 

776 for h, hk in title_kinds.items() 

777 if h != t and (hk & kinds or h in kinds)), 

778 NON_GIVEN_NAME_PARTICLES=frozenset(never_given), 

779 PARTICLES=frozenset(particles), 

780 CONJUNCTIONS=frozenset(conjunctions), 

781 CONJUNCTIONS_AMBIGUOUS=frozenset(initial_conjunctions), 

782 BOUND_GIVEN_NAMES=frozenset(bound), 

783 # a given name written short (`chas`, Filipino `ma`): opening the 

784 # given part period-marked, it is the given name, which outranks 

785 # rules/H2's shape (#789) 

786 GIVEN_NAME_ABBREVIATIONS=frozenset(abbreviations), 

787 MAIDEN_MARKERS=frozenset(markers), 

788 SUFFIX_WORDS=frozenset(words), 

789 GLUED_HONORIFICS=frozenset(glued), 

790 SUFFIX_WORDS_AMBIGUOUS=frozenset(ambiguous_words), 

791 AGNOMENS=frozenset(agnomens), 

792 SUFFIX_ACRONYMS_AMBIGUOUS=frozenset(ambiguous), 

793 SUFFIX_ACRONYMS=frozenset(acronyms), 

794 CAPITALIZATION_EXCEPTIONS=dict(sorted(cased.items())), 

795 # the kinds the compiled sets above do not record, which the 

796 # generation, credentials, honorifics and patronymic views read (rules/R8, 

797 # rules/R9): a suffix WORD's kind, `jr` and `esq` both being in 

798 # SUFFIX_WORDS, and a particle's. Internal, re-exported by no 

799 # config module (#754, 4i-b) 

800 GENERATION_WORDS=frozenset(generations), 

801 CREDENTIAL_WORDS=frozenset(credential_words), 

802 HONORIFIC_WORDS=frozenset(honorific_words), 

803 PATRONYMIC_PARTICLES=frozenset(patronymics), 

804 # the title readings written short (`abbreviation`), which H5's 

805 # trailing chain takes where the word may also be a given name 

806 # (#999); internal, like the kind sets above 

807 TITLE_ABBREVIATIONS=frozenset(written_short), 

808 # the suffix words whose Title case is their writing as a name 

809 # (`fils`, `kc`): of a word gated by a name reading, the one 

810 # kind whose writing leans (rules/S6, S11, #1039) 

811 TITLE_CASE_NAME_WORDS=frozenset(title_case_names), 

812 # the names a community gives every member (Sikh `kaur`, 

813 # `singh`), no personal or family name of the bearer's own, 

814 # which the two-name switches neither move nor complete 

815 # (rules/O6, O7, #704); internal, like the kind sets above 

816 SHARED_NAMES=frozenset(shared), 

817 # the given names written behind a particle as the second of a 

818 # compound given name (`del Carmen`, `de los Ángeles`), which 

819 # rules/P10 reads as a middle name behind a one-word given name 

820 # (#704) 

821 PARTICLE_GIVEN_NAMES=frozenset(after_particle), 

822 # the particles naming a saint (`san`, `santa`, `st`), which 

823 # open a place name or a surname (`Santa María`), never a given 

824 # name's second, so a given name phrase holds none (#704) 

825 SAINT_PARTICLES=frozenset(saints), 

826 # the particles of the kinds written with a capital (`St`, 

827 # `San`, `Freiherr`, `Graf`), which case repair does not 

828 # lowercase, rules/R4 (#1069) 

829 CAPITALIZED_PARTICLES=frozenset(capitalized), 

830 # the particles written `capital_opening` (`ter`, `der`), which 

831 # case repair writes with a capital where no particle stands 

832 # before one in the family name, nor, where it opens the family 

833 # name, written right before it in any role, rules/R12 

834 CAPITAL_OPENING_PARTICLES=frozenset(capital_opening), 

835 # the titles written `dotted` (`us`, `uk`, `pd`), which a run of 

836 # single letters joined by full stops spells at the head of a 

837 # name where a title the run keeps stands behind it, rules/H10 

838 DOTTED_TITLES=frozenset(dotted), 

839 # the titles written `attached` (side "before"), which 

840 # rules/W6's front peel splits off the start of the name's first 

841 # word they are glued to (`นายสมชาย`, #317) 

842 # a title that may be a given name is a title only in a phrase 

843 # (rules/H9), so none is split off a glued word: the given name 

844 # it opens would be cut and no title read (#317) 

845 GLUED_TITLES=frozenset(glued_titles - contested), 

846 # `completed_by` resolved to the two-word given names it lists, 

847 # the bound word and each word that completes it ('عبد الله'), 

848 # which rules/P12 joins wherever they stand (#694) 

849 BOUND_PHRASES=frozenset(bound_phrases), 

850 # every reading's `same_as` in the group of its kind: the words 

851 # it is a listed spelling of (`wm` of `william` among given 

852 # names, `junior` of `jr` among suffixes, `sr` of both `señor` 

853 # and `senhor` among titles), which no set above records 

854 SAME_AS={g: {w: tuple(sorted(heads)) 

855 for w, heads in sorted(same_as[g].items())} 

856 for g in sorted(same_as)}, 

857 # the derived marker of a listed spelling: a word whose name 

858 # reading carries a `same_as` beside a reading of the same kind 

859 # with none, a name written short that is a name of its own too 

860 # (`jo`, José written short and the given name Jo), whose links 

861 # the name match reads only where the word is written with its 

862 # period (decisions/name-matching, 2026-11-08 #1006 follow-up). 

863 # A bound given name's or a particle's `same_as` is a spelling 

864 # written without one (`ابو` of `أبو`), and marks nothing 

865 SAME_AS_NAMED={g: frozenset( 

866 w for k, short in short_names.items() 

867 if same_as_group(k) == g 

868 for w in short & own_names.get(k, set())) 

869 for g in sorted(same_as)}, 

870 # a familiar form and the longer given names it stands for, one 

871 # way (`max` of `maxim` and `maximilian`, which do not stand for 

872 # each other), every file's together: read by the name match's 

873 # opt-in `familiar-form` alone, never by a parse 

874 # (decisions/name-matching, 2026-11-08 FAMILIAR FORMS) 

875 FAMILIAR={w: tuple(sorted(longs)) 

876 for w, longs in sorted(familiar.items())}, 

877 ) 

878 # One form per post-nominal: the word test strips only edge periods 

879 # and the acronym test strips all of them, so a word in both is 

880 # matched twice by two rules, and which one fired is unreadable from 

881 # outside. The sharper stake is the ambiguous set's: suffix_as_written 

882 # ORs the two branches, so an ambiguous acronym that were also a word 

883 # would be claimed through the word and bypass the period gate the 

884 # marking exists to impose. The single overlap was 'esq', dropped 

885 # 2026-09-08 (decisions/suffix-acronym-collisions). Lexicon guards 

886 # only that ambiguous half (its gate-bypass raise), so a caller may 

887 # still put an unambiguous word in both. In the TOML the overlap is 

888 # unreachable -- the build's check 4 refuses two suffix kinds on one 

889 # word, check 5 one kind with `acronym` differing between files, and 

890 # check 2 a word twice in one file -- so this is a backstop kept 

891 # because it costs one set intersection, not a check any input 

892 # reaches today. 

893 both = out["SUFFIX_ACRONYMS"] & out["SUFFIX_WORDS"] 

894 if both: 

895 raise CompileError(f"{sorted(both)}: a post-nominal is an acronym or a " 

896 f"word, never both (check `acronym` in every file)") 

897 return out