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