Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/nameparser/_match.py: 30%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1"""Name matching: the settings object, the result types and the check.
3Not a stage: nothing here runs inside a parse. `Matching` carries a
4`Parser`, the set of outcomes the caller accepts and the switch for
5other readings, and its `check` compares two parsed names role by
6role.
7Produces: `Outcome`, `Side`, `RoleMatch`, `CrossRoleMatch`, `MatchResult`,
8`DEFAULT_ACCEPT` and `Matching`.
9Reads: the `Parser`'s `Lexicon`, for the listed spellings (`same_as`)
10through `Lexicon._headwords`, each role in the group of its kind
11(its `_RULES` row), for the familiar forms (`short_for`) through
12`Lexicon._familiar_forms`, and for `Lexicon.particles`, a hyphen half
13that is a listed particle being set aside, and its `revise`, for a
14name's other readings (`Matching.other_readings`); no `Policy` field.
16Consistent means nothing in the two names contradicts it, under the
17outcomes the caller accepts. The module imports `_types`, `_policy`
18(for the scripts that write no initials, and `Policy()`, which the
19repr compares a parser's policy with) and `_lexicon` (for
20`_normalize`, which folds a hyphen half before it is looked up among
21the particles, and `Lexicon.default()`, which the repr compares a
22parser's lexicon with), `_pipeline._vocab` (for `_HYPHEN_CHARS`, the
23hyphens the pipeline joins two names with, every one of which the
24match reads as the hyphen-minus, and `_HYPHENS`, the pattern a word
25is cut at them by), `_parser` at call time, in the constructor and
26the repr, to tell a `Parser` from anything else, and `Parser` for
27typing too.
28"""
29from __future__ import annotations
31import dataclasses
32import re
33from collections.abc import Callable, Iterable, Iterator, Mapping
34from dataclasses import dataclass
35from enum import StrEnum
36from typing import TYPE_CHECKING
38from nameparser._lexicon import FULL_STOPS, Lexicon, _normalize
39from nameparser._lexicon import _fold as _lexicon_fold
40from nameparser._pipeline._vocab import _HYPHEN_CHARS, _HYPHENS
41from nameparser._policy import _NO_INITIALS, Policy, _script_matcher
42from nameparser._types import (
43 UNJOINED_TAG,
44 Ambiguity,
45 ParsedName,
46 Role,
47 _coerce_enum,
48 _guarded_getstate,
49 _guarded_setstate,
50)
52if TYPE_CHECKING:
53 from nameparser._parser import Parser
56class Outcome(StrEnum):
57 """How one role of one name compares with the same role of another,
58 listed in the order `check` ranks them, strongest first. `strength`
59 is that rank as an ordinal, larger for a stronger outcome and 0 for
60 `conflict`; compare outcomes by it, since `<` on an Outcome
61 compares the names as strings (`folded` sorts before `same`), but
62 never store one: the values change when an outcome is added.
63 `conflict` is the one outcome that is never accepted. The search
64 for each side's other readings is not an outcome but a switch,
65 `Matching.other_readings`, and the reading used is named in
66 `MatchResult.left_alternative` and `right_alternative`."""
68 SAME = "same"
69 FOLDED = "folded"
70 JOINED = "joined"
71 LISTED_SPELLING = "listed-spelling"
72 PARTICLES_ASIDE = "particles-aside"
73 PART_OF_OTHER = "part-of-other"
74 INITIAL = "initial"
75 INITIALS_AGREE = "initials-agree"
76 ONE_SIDE = "one-side"
77 GENERATION_ONE_SIDE = "generation-one-side"
78 NOTED = "noted"
79 FAMILIAR_FORM = "familiar-form"
80 FAMILY_INITIAL = "family-initial"
81 GIVEN_ONE_SIDE = "given-one-side"
82 HYPHENATED_PART = "hyphenated-part"
83 ACROSS_ROLES = "across-roles"
84 CONFLICT = "conflict"
86 @property
87 def strength(self) -> int:
88 """The outcome's rank as an ordinal to compare outcomes by:
89 `conflict` is 0, and each outcome declared before another is
90 stronger than it, `same` the strongest. The values change when
91 an outcome is added, so compare them, never store one."""
92 return _STRENGTH[self]
95#: Each outcome's strength, from its declaration order.
96_STRENGTH: Mapping[Outcome, int] = {
97 o: len(Outcome) - 1 - i for i, o in enumerate(Outcome)}
100class Side(StrEnum):
101 """Which side of a comparison carried more of the role's text."""
103 LEFT = "left"
104 RIGHT = "right"
105 NEITHER = "neither"
108_MIRROR = {Side.LEFT: Side.RIGHT, Side.RIGHT: Side.LEFT, Side.NEITHER: Side.NEITHER}
111@dataclass(frozen=True, slots=True)
112class RoleMatch:
113 """One role's comparison: the outcome, both sides' text, and which
114 side carried more. On a row read `across-roles`, the side carrying
115 more is the one whose name held both the role and the cross role
116 the match used (`J. Edgar Hoover` / `Edgar Hoover`: left)."""
118 role: Role
119 outcome: Outcome
120 left: str
121 right: str
122 more: Side
124 __getstate__ = _guarded_getstate
125 __setstate__ = _guarded_setstate
127 def mirrored(self) -> RoleMatch:
128 """The same comparison with the two names swapped."""
129 return RoleMatch(self.role, self.outcome, self.right, self.left,
130 _MIRROR[self.more])
133@dataclass(frozen=True, slots=True)
134class CrossRoleMatch:
135 """A role of one name matched against a different role of the
136 other (`across-roles`): the left name's role and text, the right
137 name's role and text, and the outcome of comparing the two texts."""
139 left_role: Role
140 right_role: Role
141 outcome: Outcome
142 left: str
143 right: str
145 __getstate__ = _guarded_getstate
146 __setstate__ = _guarded_setstate
148 def mirrored(self) -> CrossRoleMatch:
149 """The same match with the two names swapped."""
150 return CrossRoleMatch(self.right_role, self.left_role, self.outcome,
151 self.right, self.left)
154@dataclass(frozen=True, slots=True)
155class MatchResult:
156 """The result of comparing two names: whether nothing in them
157 contradicts the match under the accepted outcomes, one `RoleMatch`
158 per `Role`, in `Role` order, the `CrossRoleMatch` behind each
159 role reading `across-roles`, in `Role` order of the role it
160 resolved (empty unless `across-roles` is accepted), and on each
161 side the `Ambiguity` whose other reading the table used, or `None`
162 where that side is read as parsed (always `None` unless
163 `other_readings` is on). A side uses one other reading at a
164 time; each row keeps its own outcome under it.
166 A row is read by its role, a `Role` member or its name
167 (`result["suffix"]`), and `weakest` is the weakest row's outcome."""
169 consistent: bool
170 roles: tuple[RoleMatch, ...]
171 across: tuple[CrossRoleMatch, ...] = ()
172 left_alternative: Ambiguity | None = None
173 right_alternative: Ambiguity | None = None
175 __getstate__ = _guarded_getstate
176 __setstate__ = _guarded_setstate
178 def __post_init__(self) -> None:
179 order = ", ".join(r.value for r in Role)
180 if not isinstance(self.roles, tuple):
181 raise TypeError(
182 f"roles must be a tuple of RoleMatch, one per Role, got "
183 f"{type(self.roles).__name__}; pass tuple(rows)")
184 for row in self.roles:
185 if not isinstance(row, RoleMatch):
186 raise TypeError(
187 f"roles must hold RoleMatch rows, got "
188 f"{type(row).__name__} {row!r}; build each row as "
189 f"RoleMatch(role, outcome, left, right, more)")
190 held = [row.role for row in self.roles]
191 if held != list(Role):
192 raise ValueError(
193 f"roles must hold one RoleMatch per Role, in Role order "
194 f"({order}), got {', '.join(str(r) for r in held) or 'none'}; "
195 f"pass a row for every role, in that order")
197 def __getitem__(self, role: Role | str) -> RoleMatch:
198 """The row of one role: a `Role` member or its name; anything
199 else raises ValueError naming the valid roles."""
200 return self.roles[_RANK_ROLE[_coerce_enum(role, Role, "Role", "roles")]]
202 @property
203 def weakest(self) -> Outcome:
204 """The weakest row's outcome, by `Outcome.strength`."""
205 return _weakest(r.outcome for r in self.roles)
207 def mirrored(self) -> MatchResult:
208 """The same result with the two names swapped: each role's left
209 and right text exchanged and its `more` side flipped, each
210 cross-role match's roles and texts exchanged, and the two sides'
211 alternatives exchanged."""
212 return MatchResult(self.consistent,
213 tuple(m.mirrored() for m in self.roles),
214 tuple(c.mirrored() for c in self.across),
215 self.right_alternative, self.left_alternative)
218#: The outcomes accepted when a caller states none.
219DEFAULT_ACCEPT: frozenset[Outcome] = frozenset({
220 Outcome.SAME, Outcome.FOLDED, Outcome.JOINED, Outcome.LISTED_SPELLING,
221 Outcome.PARTICLES_ASIDE, Outcome.PART_OF_OTHER, Outcome.INITIAL,
222 Outcome.INITIALS_AGREE, Outcome.ONE_SIDE, Outcome.GENERATION_ONE_SIDE,
223 Outcome.NOTED,
224})
226_VALID = ", ".join(o.value for o in Outcome if o is not Outcome.CONFLICT)
227#: The code a refused `accept` is offered: it extends the default set,
228#: which a bare `accept=` would replace, and type-checks.
229_ACCEPT_HINT = ('pass e.g. parser.matching(accept=parser.matching().accept'
230 ' | {"family-initial"})')
231#: The spelling of the other-reading search before it became a switch,
232#: refused with the call that replaces it.
233_OTHER_READING = "other-reading"
234#: The code a refused `other-reading` member is offered; it type-checks.
235_OTHER_READINGS_HINT = "parser.matching(other_readings=True)"
238def _outcome(value: object) -> Outcome:
239 # not _coerce_enum: its message would list `conflict` among the
240 # valid values, which accept refuses
241 if isinstance(value, Outcome):
242 return value
243 if isinstance(value, str):
244 if value == _OTHER_READING:
245 raise ValueError(
246 f"{_OTHER_READING!r} is not an outcome: the search for each "
247 f"side's other readings is a switch; drop it from accept, "
248 f"keep the rest, and pass other_readings=True beside it, "
249 f"e.g. {_OTHER_READINGS_HINT}")
250 try:
251 return Outcome(value)
252 except ValueError:
253 raise ValueError(
254 f"unknown outcome {value!r}; valid outcomes: {_VALID}"
255 ) from None
256 raise TypeError(
257 f"accept must be an iterable of outcome names or Outcome members, "
258 f"got a member {value!r} of type {type(value).__name__}; "
259 f"{_ACCEPT_HINT}")
262@dataclass(frozen=True, slots=True)
263class Matching:
264 """The settings of a name match: the parser that reads the names and
265 the outcomes accepted as consistent, and whether each side's other
266 readings are searched.
268 `accept` takes any iterable of `Outcome` members or their names and is
269 stored as a frozenset with `same` always present. Passing it replaces
270 the default set rather than extending it, so extend the default
271 matcher's set: `parser.matching(accept=parser.matching().accept |
272 {"family-initial"})`. `conflict` cannot be accepted.
274 `other_readings`, a bool, off by default, switches on the search
275 for each side's other readings where the names as parsed are not
276 consistent (see `check`), and the keys of each reading searched
277 (see `keys`). It is not an outcome: no row reads it, and each row
278 keeps its own outcome under the reading used.
279 """
281 parser: Parser
282 accept: frozenset[Outcome] = DEFAULT_ACCEPT
283 other_readings: bool = False
285 __getstate__ = _guarded_getstate
286 __setstate__ = _guarded_setstate
288 def __post_init__(self) -> None:
289 # Parser at call time: _parser imports this module at call time
290 # too, in Parser.matching
291 from nameparser._parser import Parser
292 if not isinstance(self.parser, Parser):
293 raise TypeError(
294 f"parser must be a Parser, got {type(self.parser).__name__}; "
295 f"pass Parser() or use Parser.matching()")
296 accept = self.accept
297 if isinstance(accept, (str, bytes, Mapping)) or not isinstance(accept, Iterable):
298 raise TypeError(
299 f"accept must be an iterable of outcome names, got "
300 f"{type(accept).__name__} {accept!r}; {_ACCEPT_HINT}")
301 outcomes = frozenset(_outcome(v) for v in accept)
302 if Outcome.CONFLICT in outcomes:
303 raise ValueError(
304 f"'conflict' cannot be accepted: it is the outcome of a "
305 f"contradiction; drop it from accept (valid outcomes: "
306 f"{_VALID})")
307 object.__setattr__(self, "accept", outcomes | {Outcome.SAME})
308 if not isinstance(self.other_readings, bool):
309 raise TypeError(
310 f"other_readings must be a bool, got "
311 f"{type(self.other_readings).__name__} "
312 f"{self.other_readings!r}; pass other_readings=True or "
313 f"other_readings=False")
315 def __repr__(self) -> str:
316 added = sorted(o.value for o in self.accept - DEFAULT_ACCEPT)
317 removed = sorted(o.value for o in DEFAULT_ACCEPT - self.accept)
318 if self.other_readings:
319 # a switch, not an outcome, so it follows the outcomes added
320 added.append("other-readings")
321 parts = ["default"]
322 if added:
323 parts.append("+ " + ", ".join(added))
324 if removed:
325 parts.append("- " + ", ".join(removed))
326 return f"Matching({' '.join(parts)}{_parser_repr(self.parser)})"
328 def check(self, a: str | ParsedName, b: str | ParsedName) -> MatchResult:
329 """Compare two names role by role. A string is parsed with this
330 settings object's parser. The result is consistent when every
331 role's outcome is among the accepted ones; `conflict` never is,
332 and an empty family role on either side is a conflict. The
333 check is symmetric: swapping the names gives the mirrored
334 result. It is not transitive: `J. Smith` is consistent with
335 `John Smith` and with `Jane Smith`, which are not consistent
336 with each other.
338 Where `other_readings` is on and the names as parsed are
339 not consistent, each side's other readings are tried too: one
340 per `Ambiguity` with an `alternative`, its words moved to the
341 alternative's role, one ambiguity at a time and never two
342 together, save an alternative into the title or suffix, which
343 is not tried: a title never conflicts and a suffix only on its
344 generation, so the move could only empty a role that does.
345 The table reported is the one with the strongest outcomes, each
346 row keeping its own outcome, and the result names in
347 `left_alternative` and `right_alternative` the ambiguity whose
348 other reading each side used.
350 Where `across-roles` is accepted, a given or family role whose
351 outcome is not accepted is compared with a different role of
352 the other name: a given name with each word of the other's
353 middle name and with its nickname, a family name with the
354 other's maiden name. Only a full name on both sides counts, at
355 `same`, `folded`, `joined` or `listed-spelling`, never an
356 initial. The
357 strongest match resolves the role, which then reads
358 `across-roles`, and is recorded in the result's `across`; a
359 middle word it used is taken out of the middle comparison, the
360 words left compared role to role. No cross-role match is tried
361 where `across-roles` is not accepted. The family role must still
362 agree, here directly or through the maiden name."""
363 left, right = self._parsed(a), self._parsed(b)
364 best = self._result(left, right)
365 if best.consistent or not self.other_readings:
366 return best
367 # each side's first reading is the name as parsed, so the pair
368 # of those is `best`, already built
369 candidates = [best, *(
370 self._result(ra, rb, alt_a, alt_b)
371 for ra, alt_a in self._readings(left)
372 for rb, alt_b in self._readings(right)
373 if alt_a is not None or alt_b is not None)]
374 return min(candidates, key=_strength)
376 def keys(self, name: str | ParsedName) -> frozenset[str]:
377 """Grouping keys: for each word of the folded family base, and
378 each half of a hyphenated one (cut at any of the pipeline's
379 hyphens, each folded to the hyphen-minus) beside its joined and
380 its whole form, a half that is a listed particle left out (`Al-Saud` keys
381 `saud`, `alsaud` and `al-saud`, never `al`), and for the family's
382 whole text, particles kept, folded and joined (`de la Vega` keys
383 `delavega` beside `vega`), one key `word;g` where `g` is the
384 first letter of the given name once joined (empty where there is
385 none; `-Pierre` gives `p`). A word is keyed by every word in its spelling
386 set among surnames (itself and the words it is listed as a
387 spelling of, read as written and folded, each folded), and a
388 given name by the first letter of every word in its set among
389 given names, where a word that is a name of its own as well
390 (`jo`) contributes only itself unless written with its trailing
391 period, the one spelling set the check reads (`_spellings`),
392 and where `familiar-form` is accepted also by the first letter
393 of every name a word of that set is paired with directly as a
394 familiar form, in either direction (`Bill` and `William` each
395 key both `b` and `w`).
396 The guarantee rests on this: two words the check
397 reads as listed spellings have sets that meet, so they share a
398 member and with it a key; every family-base word the default
399 check compares is keyed, and so is the whole family joined,
400 which two family names read `joined` share, so two names it finds
401 consistent share a key and grouping by key loses no match. A
402 name with no family has no key.
404 Each accepted opt-in adds the keys its outcome needs:
405 `familiar-form` the given letters of the paired names (above),
406 `family-initial` a key `f;g` for the first letter `f` of the
407 family's first word, `given-one-side` a bare key for every
408 family part keyed and `across-roles` the maiden name's
409 words without particles as family words, and its whole text
410 joined, and as given letters
411 the first letter of each middle word and of the nickname's
412 first word, save a middle word or a nickname written in
413 initials alone, which the check never matches across roles.
414 Where `other_readings` is on, each other reading `check` tries
415 adds its own keys."""
416 parsed = self._parsed(name)
417 readings = (self._readings(parsed) if self.other_readings
418 else [(parsed, None)])
419 return frozenset().union(
420 *(self._keys(reading) for reading, _ in readings))
422 def _result(self, left: ParsedName, right: ParsedName,
423 left_alternative: Ambiguity | None = None,
424 right_alternative: Ambiguity | None = None) -> MatchResult:
425 """The table of two readings, each side's alternative the
426 ambiguity whose other reading it is, `None` for a name as
427 parsed."""
428 lexicon = self.parser.lexicon
429 roles = [_compare_role(role, left, right, lexicon, self.accept)
430 for role in Role]
431 across: list[CrossRoleMatch] = []
432 if Outcome.ACROSS_ROLES in self.accept:
433 middles = [_role_text(left, Role.MIDDLE).split(),
434 _role_text(right, Role.MIDDLE).split()]
435 for i, r in enumerate(roles):
436 if r.outcome in self.accept or not _RULES[r.role].across:
437 continue
438 found = self._pick_across(r.role, left, right, middles)
439 if found is None:
440 continue
441 cross, index = found
442 # the side carrying more is the one whose name held both
443 # the role and the cross role the match used
444 holder = Side.RIGHT if cross.left_role is r.role else Side.LEFT
445 held = r.left if holder is Side.LEFT else r.right
446 roles[i] = dataclasses.replace(
447 r, outcome=Outcome.ACROSS_ROLES,
448 more=holder if held else Side.NEITHER)
449 across.append(cross)
450 if index is not None:
451 # the middle word matched as the given name is
452 # consumed, and the words left compare role to role,
453 # the row keeping both sides' text as parsed
454 del middles[0 if cross.left_role is Role.MIDDLE
455 else 1][index]
456 la, lb = (" ".join(words) for words in middles)
457 outcome = _role_outcome(_RULES[Role.MIDDLE], la, lb,
458 left, right, lexicon, self.accept)
459 roles[_MIDDLE] = dataclasses.replace(
460 roles[_MIDDLE], outcome=outcome,
461 more=(Side.NEITHER if outcome is Outcome.CONFLICT
462 else _more(la, lb)))
463 return MatchResult(all(r.outcome in self.accept for r in roles),
464 tuple(roles), tuple(across),
465 left_alternative, right_alternative)
467 def _pick_across(self, role: Role, left: ParsedName, right: ParsedName,
468 middles: list[list[str]],
469 ) -> tuple[CrossRoleMatch, int | None] | None:
470 """The cross-role match `role` takes (`_across`), judged with
471 the middle names it leaves: a match that consumes a middle word
472 leaves the rest to compare, so the matches are ranked by
473 whether that comparison is accepted, then by the match's own
474 outcome, then by that comparison's outcome, then as `_across`
475 orders them, which names no side, so the pick is the same with
476 the names swapped (decisions/name-matching, 2026-11-09 #1263).
477 `middles` holds each side's middle words still to compare."""
478 found = self._across(role, left, right)
479 if not found:
480 return None
481 lexicon, rule = self.parser.lexicon, _RULES[Role.MIDDLE]
483 def rank(item: tuple[tuple[object, ...],
484 tuple[CrossRoleMatch, int | None]]
485 ) -> tuple[object, ...]:
486 order, (cross, index) = item
487 rest = [list(words) for words in middles]
488 if index is not None:
489 del rest[0 if cross.left_role is Role.MIDDLE else 1][index]
490 la, lb = (" ".join(words) for words in rest)
491 middle = _role_outcome(rule, la, lb, left, right, lexicon,
492 self.accept)
493 return (middle not in self.accept, -_STRENGTH[cross.outcome],
494 -_STRENGTH[middle], *order)
495 return min(found, key=rank)[1]
497 def _across(self, role: Role, left: ParsedName, right: ParsedName,
498 ) -> list[tuple[tuple[object, ...],
499 tuple[CrossRoleMatch, int | None]]]:
500 """Every match of `role` on one side against one of its cross
501 roles (its `_RULES` row's `across`) on the other, each with the
502 index of the middle word it used (`None` for a role other than
503 the middle; the match's middle role names the side) and its
504 place in the order that names no side: by outcome, then by the
505 cross role, then by texts. A middle name is read word by word, the other roles
506 whole. Only a full name on both sides counts (`_full_outcome`),
507 never an initial."""
508 lexicon = self.parser.lexicon
509 rule = _RULES[role]
510 found: list[tuple[tuple[object, ...],
511 tuple[CrossRoleMatch, int | None]]] = []
512 for other in rule.across:
513 for lrole, rrole in ((other, role), (role, other)):
514 rights = [(t, _fold(t)) for t in _cross_texts(right, rrole)]
515 for li, la in enumerate(_cross_texts(left, lrole)):
516 fla = _fold(la)
517 for ri, (ra, fra) in enumerate(rights):
518 outcome = _full_outcome(la, ra, fla, fra, lexicon,
519 rule.group)
520 if (outcome is None or outcome not in self.accept
521 or _initials_only(fla) or _initials_only(fra)):
522 continue
523 theirs, mine = (la, ra) if lrole is other else (ra, la)
524 index = (None if other is not Role.MIDDLE
525 else li if lrole is other else ri)
526 found.append((
527 (-_STRENGTH[outcome], _RANK_ROLE[other], theirs, mine),
528 (CrossRoleMatch(lrole, rrole, outcome, la, ra),
529 index)))
530 return found
532 def _readings(self, name: ParsedName
533 ) -> list[tuple[ParsedName, Ambiguity | None]]:
534 """The name as parsed, with no ambiguity, then one other
535 reading per ambiguity whose `alternative` moves a word into a
536 role whose `_RULES` row has `tried_into`: that ambiguity's words
537 moved to the alternative's role by `Parser.revise`, with the
538 ambiguity."""
539 readings: list[tuple[ParsedName, Ambiguity | None]] = [(name, None)]
540 for ambiguity in name.ambiguities:
541 if ambiguity.alternative is None:
542 continue
543 target = ambiguity.alternative.role
544 if not _RULES[target].tried_into:
545 continue
546 words = [t for t in ambiguity.tokens if t.role is not target]
547 if not words:
548 continue
549 moved = frozenset({target, *(t.role for t in words)})
550 # the moved name's texts, read off its own views so a word
551 # a division cut keeps its halves joined
552 laid_out = ParsedName(name.original, tuple(
553 dataclasses.replace(t, role=target, reading=None)
554 if t in words else t for t in name.tokens))
555 readings.append((self.parser.revise(
556 name, **{r.value: getattr(laid_out, r) for r in moved}),
557 ambiguity))
558 return readings
560 def _keys(self, parsed: ParsedName) -> frozenset[str]:
561 lexicon = self.parser.lexicon
562 across = Outcome.ACROSS_ROLES in self.accept
563 family_rule, given_rule = _RULES[Role.FAMILY], _RULES[Role.GIVEN]
564 bases = [parsed.family_base]
565 if across:
566 # the texts the check matches against a family name, each
567 # without its particles as the family base is
568 bases += [_base_of(parsed, other) for other in family_rule.across]
569 forms: list[str] = []
570 for base in bases:
571 forms += _family_words(base, lexicon)
572 # a hyphenated word joined and whole too: either may itself
573 # be the full form another word is listed as a spelling of
574 forms += [form for raw in base.split() if _HYPHENS.search(raw)
575 for form in (_HYPHENS.sub("", raw), raw)]
576 words = set().union(*(_spelling_keys(form, family_rule.group, lexicon)
577 for form in forms if form))
578 # the whole text the check compares a family name by, particles
579 # kept, joined (`La Russa` and `LaRussa` key `larussa`), and under
580 # across-roles each text it compares the family against
581 texts = [parsed.family]
582 if across:
583 texts += [t for other in family_rule.across
584 for t in _cross_texts(parsed, other)]
585 words |= {_joined(_fold(t)) for t in texts if t} - {""}
586 family = parsed.family.split()
587 if family and words and Outcome.FAMILY_INITIAL in self.accept:
588 words |= {f[:1] for f in _spelling_keys(
589 family[0], family_rule.group, lexicon)} - {""}
590 given = _lettered_words(_role_text(parsed, Role.GIVEN))
591 # a given letter is the first letter once joined, as the check
592 # compares by `joined` too (`-Pierre` keys `p`, not `-`)
593 initials = {_letter(g) for g in _spelling_keys(
594 given[0], given_rule.group, lexicon)} - {""} if given else set()
595 if given and Outcome.FAMILIAR_FORM in self.accept:
596 # every name the check pairs with it as a familiar form,
597 # either way (`_familiar`), so `Bill` and `William` share
598 # each other's keys
599 initials |= {_letter(g) for g in _paired_forms(
600 _spellings(given[0], given_rule.group, lexicon), lexicon)} - {""}
601 # no given letter, a name with no given name or one of hyphens
602 # alone, keys the empty letter, as the cross-role letters below
603 # stand beside it rather than for it
604 initials = initials or {""}
605 if across:
606 # the texts the check matches against a given name, each
607 # refused as it refuses them: a cross-role match is never
608 # made by initials alone (`_initials_only`, so `J.-P.` is
609 # not keyed); a text is keyed by its first word's letter
610 for other in given_rule.across:
611 for text in _cross_texts(parsed, other):
612 if not _initials_only(_fold(text)):
613 initials |= {_letter(g) for g in _spelling_keys(
614 _lettered_words(text)[0], given_rule.group,
615 lexicon)} - {""}
616 keys = {f"{w};{g}" for w in words for g in initials}
617 if Outcome.GIVEN_ONE_SIDE in self.accept:
618 keys |= words
619 return frozenset(keys)
621 def _parsed(self, name: object) -> ParsedName:
622 if isinstance(name, ParsedName):
623 return name
624 if isinstance(name, str):
625 return self.parser.parse(name)
626 raise TypeError(
627 f"check takes a name string or a ParsedName, got "
628 f"{type(name).__name__} {name!r}; pass the text, or "
629 f"parser.parse(text)")
632def _parser_repr(parser: Parser) -> str:
633 """The parser's part of `Matching`'s repr: nothing for a `Parser`
634 reading as `Parser()` does (the default lexicon and policy, no
635 segmenter), the `Parser`'s own bounded repr for any other, and for
636 anything else, a subclass of `Parser` included, or a `Parser`
637 holding a subclass of `Lexicon` or `Policy`, its class name alone,
638 since a subclass's repr and equality may be anything, as `Parser`
639 names a segmenter by its class. A `Parser` missing a field (built
640 past its constructor) is named by its class too, the fields read
641 with `getattr`, so the repr never raises."""
642 # Parser at call time: _parser imports this module at call time
643 from nameparser._parser import Parser
644 missing = object()
645 lexicon, policy, segmenter = (
646 getattr(parser, f, missing) for f in _PARSER_FIELDS)
647 if (type(parser) is not Parser or type(policy) is not Policy
648 or type(lexicon) is not Lexicon or segmenter is missing):
649 return f", parser={type(parser).__name__}"
650 if segmenter is None and policy == Policy() and lexicon == Lexicon.default():
651 return ""
652 return f", parser={parser!r}"
655#: The fields of `Parser` the repr compares with `Parser()`'s, every
656#: field it has (test_match.py holds the two equal, so a field added to
657#: `Parser` is compared or the test fails).
658_PARSER_FIELDS = ("lexicon", "policy", "segmenter")
661# -- the check -------------------------------------------------------------
663#: Every hyphen the pipeline joins two names with in one written word
664#: (rules/O6's set, `_pipeline/_vocab._HYPHEN_CHARS`), each read as the
665#: hyphen-minus: `_fold` maps them, and a raw text is cut at any of them
666#: (`_HYPHENS`), so `Smith‐Jones` (U+2010) and `Thomas–Symonds` (the en
667#: dash) split as `Smith-Jones` does, on both sides and in the keys
668#: (decisions/name-matching, 2026-11-11 #1264).
669_TO_HYPHEN_MINUS = str.maketrans(dict.fromkeys(_HYPHEN_CHARS, "-"))
670#: Spaces and hyphens in a FOLDED text, where every hyphen of
671#: `_HYPHEN_CHARS` is the hyphen-minus already.
672_WORDS = re.compile(r"[\s-]+")
673#: rules/R7's scripts that write no initials (_policy._NO_INITIALS): a
674#: single character in one of them is a whole word, never an initial.
675_initialless = _script_matcher(*_NO_INITIALS, whole=True)
676_RANK_ROLE = {r: i for i, r in enumerate(Role)}
677#: `FULL_STOPS` as the tuple `str.endswith` takes, one test for the
678#: period `_spellings` reads off a word as written.
679_FULL_STOPS = tuple(FULL_STOPS)
680_MIDDLE = _RANK_ROLE[Role.MIDDLE]
683def _fold(text: str) -> str:
684 """The lexicon's fold (`_lexicon._fold`: case, accents, full stops
685 and apostrophes), then every hyphen of `_HYPHEN_CHARS` written as
686 the hyphen-minus, so two texts differing by their hyphen character
687 alone are `folded`, as two differing by their apostrophe are."""
688 return _lexicon_fold(text).translate(_TO_HYPHEN_MINUS)
691def _base_of(name: ParsedName, role: Role) -> str:
692 """A role's text without its particles, as `family_base` writes the
693 family's: the maiden's, which the check's maiden row compares
694 (`_compare_maiden`) and `across-roles` keys as family words."""
695 return name._text_for(role, without_tag="particle",
696 unless_tag=UNJOINED_TAG)
699def _cross_texts(name: ParsedName, role: Role) -> list[str]:
700 """The texts of `role` a cross-role match compares: a middle name's
701 words one by one, any other role whole."""
702 if role is Role.MIDDLE:
703 return _role_text(name, role).split()
704 text = _role_text(name, role)
705 return [text] if text else []
708def _role_text(name: ParsedName, role: Role) -> str:
709 """The text of `role` the name match reads: the field as written,
710 save that in the given and middle names two initials written with
711 nothing between them are two words, as rules/T7 parts a given name
712 written as a run of initials ('J.R.R. Tolkien' has the middle names
713 'R. R.', the field writing them 'R.R.'), so each compares as the
714 initial it is. A run T7 leaves whole, as in the middle names of
715 'John E.T. Smith', is one token and compares as one word, though
716 `initials()` reads it letter by letter (rules/R3) -- decisions/T7
717 (#1296)."""
718 text: str = getattr(name, role)
719 if role not in _RUN_ROLES or "." not in text:
720 return text
721 words: list[str] = []
722 end = -1
723 initial = False
724 for tok in name.tokens_for(role):
725 joined = tok.span is not None and tok.span.start == end
726 if words and joined and not (initial and "initial" in tok.tags):
727 words[-1] += tok.text
728 else:
729 words.append(tok.text)
730 end = -1 if tok.span is None else tok.span.end
731 initial = "initial" in tok.tags
732 return " ".join(words)
735_RUN_ROLES = frozenset({Role.GIVEN, Role.MIDDLE})
739def _strength(result: MatchResult) -> tuple[object, ...]:
740 """The order `check` picks a table by, best first: consistent, then
741 the weakest outcome strongest (then the next weakest, and so on),
742 then fewer sides read another way, then each role's text pair, each
743 cross-role match's and the two alternatives, each pair taken
744 without its sides, which keeps the pick the same with the names
745 swapped."""
746 alternatives = (result.left_alternative, result.right_alternative)
747 ranks = sorted((-_STRENGTH[r.outcome] for r in result.roles),
748 reverse=True)
749 sides = sum(a is not None for a in alternatives)
750 texts = tuple(tuple(sorted((r.left, r.right))) for r in result.roles)
751 across = tuple(sorted(((c.left, c.left_role), (c.right, c.right_role)))
752 for c in result.across)
753 return (not result.consistent, ranks, sides, texts, across,
754 sorted(_ambiguity_key(a) for a in alternatives))
757def _ambiguity_key(ambiguity: Ambiguity | None) -> tuple[str, ...]:
758 """An ambiguity as sortable text, `()` for none."""
759 if ambiguity is None:
760 return ()
761 alternative = ambiguity.alternative
762 return (ambiguity.kind.value, ambiguity.detail,
763 repr([(t.text, t.span) for t in ambiguity.tokens]),
764 alternative.role.value if alternative is not None else "")
767def _weakest(outcomes: Iterable[Outcome]) -> Outcome:
768 return min(outcomes, key=_STRENGTH.__getitem__)
771def _letters(text: str) -> int:
772 return sum(c.isalnum() for c in _fold(text))
775def _more(left: str, right: str) -> Side:
776 if left == right:
777 return Side.NEITHER
778 a, b = _letters(left), _letters(right)
779 return Side.LEFT if a > b else Side.RIGHT if b > a else Side.NEITHER
782def _is_initial(folded: str) -> bool:
783 return len(folded) == 1 and folded.isalpha() and not _initialless(folded)
786def _spellings(word: str, group: str, lexicon: Lexicon) -> frozenset[str]:
787 """The words `word` is a listed spelling of in `group`, the word
788 itself included, read from the word as written and from it folded,
789 each member beside its folded form: `Jose` among given names reaches
790 `jo`'s full form `josé` folded, so `Jo.` and `Jose` meet. A word that
791 is a name of its own as well reaches its links only written with
792 its trailing period, any full stop the vocabulary strips
793 (`FULL_STOPS`, `Jo.` too), read here off the word as written since
794 both the normalizing and the fold drop it: `Jo` is the given name Jo
795 alone (decisions/name-matching, 2026-11-08 #1006 follow-up)."""
796 period = word.endswith(_FULL_STOPS)
797 heads = (lexicon._headwords(word, group, period=period)
798 | lexicon._headwords(_fold(word), group, period=period))
799 return heads | {_fold(h) for h in heads}
802def _spelling_keys(word: str, group: str, lexicon: Lexicon) -> set[str]:
803 """The folded members of `word`'s spelling set in `group`, which
804 `keys` cuts its keys from: every spelling set holds each member's
805 folded form, so two sets that meet share a folded member. `_fold` is
806 idempotent (test_match.py holds it over every assigned code point,
807 alone and beside letters, hyphens and combining marks), so the
808 folded members are the folds of `_spellings`' own."""
809 return {_fold(h) for h in _spellings(word, group, lexicon)}
812def _listed(a: str, b: str, group: str, lexicon: Lexicon) -> bool:
813 """Two words are listed spellings of one word in `group`: their
814 spelling sets meet."""
815 return not _spellings(a, group, lexicon).isdisjoint(
816 _spellings(b, group, lexicon))
819def _part_agrees(a: str, b: str, lexicon: Lexicon,
820 group: str) -> Outcome | None:
821 """One word against one word: `same` where they are equal once
822 folded or listed as one spelling in `group`, `initial` where one is
823 an initial of the other's first letter, else `None`. A hyphenated
824 word is compared part by part against the other's hyphen-split
825 parts (`J.-P.` / `Jean-Pierre`), cut at any of the pipeline's
826 hyphens."""
827 fa, fb = _fold(a), _fold(b)
828 if fa == fb or _listed(a, b, group, lexicon):
829 return Outcome.SAME
830 if (_is_initial(fa) and fb[:1] == fa) or (_is_initial(fb) and fa[:1] == fb):
831 return Outcome.INITIAL
832 pa, pb = _HYPHENS.split(a), _HYPHENS.split(b)
833 if len(pa) > 1 and len(pa) == len(pb):
834 parts = [_part_agrees(x, y, lexicon, group) for x, y in zip(pa, pb)]
835 if None not in parts:
836 return _weakest(p for p in parts if p is not None)
837 return None
840def _paired_forms(spellings: frozenset[str], lexicon: Lexicon) -> set[str]:
841 """The given names any member of a spelling set is paired with
842 directly as a familiar form, in either direction, each folded and
843 as normalized."""
844 paired = {name for word in spellings
845 for name in lexicon._familiar_forms(word)}
846 return paired | {_fold(name) for name in paired}
849def _familiar(a: str, b: str, lexicon: Lexicon) -> bool:
850 """One given-name word and the other are a listed familiar-form
851 pair (`Bill` / `William`, `Masha` / `Maria`): a name one word's
852 spelling set is paired with directly meets the other's spelling
853 set. Read whichever way the source wrote the pair, since the
854 sources do not always write the shorter name first, and never
855 chained, so two longer names paired with one short name
856 (`Maximilian` / `Maxim`) are no pair. Symmetric in its two arguments by
857 construction: the lexicon pairs each name both ways and under its
858 fold as well (`Lexicon._familiar_forms`), and a spelling set holds
859 each member's fold, so asking from the second side finds nothing the
860 first does not (measured over every pair of the 1,034 familiar
861 names nam_dict shipped and their folds, 2026-11-08)."""
862 sa, sb = _spellings(a, "given", lexicon), _spellings(b, "given", lexicon)
863 return not _paired_forms(sa, lexicon).isdisjoint(sb)
866def _familiar_outcome(wa: list[str], wb: list[str],
867 lexicon: Lexicon) -> Outcome | None:
868 """Two given texts' words, pair by pair, each the same once folded,
869 a listed spelling or a familiar form, and at least one a familiar
870 form: `familiar-form`, else `None`."""
871 if len(wa) != len(wb):
872 return None
873 found = False
874 for x, y in zip(wa, wb):
875 if _fold(x) == _fold(y) or _listed(x, y, "given", lexicon):
876 continue
877 if not _familiar(x, y, lexicon):
878 return None
879 found = True
880 return Outcome.FAMILIAR_FORM if found else None
883def _lettered_words(text: str) -> list[str]:
884 """A text's words from the first that is not hyphens or full stops
885 alone: the word a given letter is cut from, since `joined` reads
886 `- Pierre` as `Pierre`; all of them where none is (decisions/
887 name-matching, 2026-11-09 #1261)."""
888 words = text.split()
889 for i, word in enumerate(words):
890 if _joined(_fold(word)):
891 return words[i:]
892 return words
895def _letter(folded: str) -> str:
896 """A folded word's first letter once joined, the given letter a key
897 is cut on; empty for a word of hyphens alone."""
898 return _joined(folded)[:1]
901def _joined(folded: str) -> str:
902 """A folded text without its spaces and hyphens (the fold writes
903 each as the hyphen-minus), the form `joined`
904 compares and the keys cut a family's whole key from (decisions/
905 name-matching, 2026-11-09 #1261)."""
906 return _WORDS.sub("", folded)
909def _initials_only(folded: str) -> bool:
910 """A folded text is initials alone."""
911 return all(_is_initial(w) for w in _WORDS.split(folded) if w)
914def _full_outcome(a: str, b: str, fa: str, fb: str, lexicon: Lexicon,
915 group: str) -> Outcome | None:
916 """Two non-empty texts, `fa` and `fb` their folded forms, as full
917 names: same, folded, joined (equal once folded and stripped of
918 spaces and hyphens, `La Russa` / `LaRussa`, unless both are initials
919 alone), or listed spelling (word by word, in the role's `same_as`
920 `group`), else `None`."""
921 if a == b:
922 return Outcome.SAME
923 if fa == fb:
924 return Outcome.FOLDED
925 if _joined(fa) == _joined(fb) and not (
926 _initials_only(fa) and _initials_only(fb)):
927 # two texts of initials alone are `initials-agree`'s to read
928 return Outcome.JOINED
929 wa, wb = a.split(), b.split()
930 if len(wa) == len(wb) and all(
931 _listed(x, y, group, lexicon) for x, y in zip(wa, wb)):
932 return Outcome.LISTED_SPELLING
933 return None
936def _text_outcomes(a: str, b: str, lexicon: Lexicon, group: str,
937 bases: tuple[str, str] | None = None,
938 chain: bool = True) -> Iterator[Outcome]:
939 """The outcomes that hold for two non-empty texts of one role,
940 strongest first, lazily, and last conflict, which always holds. An
941 outcome holds only where the leniency it names was needed: none is
942 yielded where a stronger one that implies it holds, nor in the
943 family role after the first of its chain (decisions/name-matching,
944 2026-11-08 FAMILIAR FORMS, the review's ruling 1).
946 - A full-name outcome (`_full_outcome`: same, folded, joined,
947 listed spelling) implies every weaker one, two texts equal once folded
948 or listed as one needing no other leniency, so it is the only
949 outcome yielded. `joined` is the exception: two texts equal once
950 their spaces and hyphens go may still need the weaker leniencies
951 read their words apart (`De La Cruz` / `Dela Cruz`, its particles
952 set aside), so they are yielded after it, and a caller who does
953 not accept `joined` keeps what they accepted before it
954 (decisions/name-matching, 2026-11-09 #1261).
955 - Given `bases`, the family role's (`_compare_family`), the outcomes are
956 treated as one chain, particles set aside, part of the other,
957 family initial, initials agree, and the first that holds is the
958 only one yielded. They are not all implied by the one before:
959 part of the other and family initial are independent leniencies
960 (`John S.` / `John S.-Jones` holds both), so a pair holding an
961 unaccepted part of the other is refused even where family
962 initial holds and is accepted.
963 - In any other role, initial against name and initials agree are
964 one leniency, the first that holds of the two yielded; a familiar
965 form (in the `given` group) and a hyphenated part are each
966 orthogonal to it and to each other, so each is yielded where it
967 holds (`Jean` / `Jean-Paul`, a caller's familiar pair and a
968 hyphenated part both).
969 - Given `bases` and not `chain`, the maiden name's
970 (`_compare_maiden`), the two leniencies that read the bases,
971 particles set aside then part of the other, the first that holds,
972 are yielded ahead of any other role's, save between two texts of
973 initials alone, which stay `initials-agree`'s to read as they are
974 `joined`'s exception (`J.-P.` / `J. P.`), so the maiden name sets its
975 particles aside as the family name does while an initial against
976 it still reads `initial`, never the opt-in `family-initial`, and
977 a hyphenated part is still yielded beside part of the other: a
978 caller keeps every maiden outcome they accepted before
979 (decisions/name-matching, 2026-11-11 #1264)."""
980 fa, fb = _fold(a), _fold(b)
981 full = _full_outcome(a, b, fa, fb, lexicon, group)
982 wa, wb = a.split(), b.split()
983 if full is not None:
984 yield full
985 if full is None or full is Outcome.JOINED:
986 if bases is not None and chain:
987 first = _family_outcome(wa, wb, fa, fb, bases, lexicon, group)
988 if first is not None:
989 yield first
990 else:
991 # two texts of initials alone are `initials-agree`'s to read,
992 # as they are `joined`'s exception
993 if bases is not None and not (
994 _initials_only(fa) and _initials_only(fb)) and (
995 aside := _aside_outcome(bases, lexicon, group)) is not None:
996 yield aside
997 initials = _initial_outcome(wa, wb, fa, fb, lexicon, group)
998 if initials is not None:
999 yield initials
1000 if group == "given" and (
1001 familiar := _familiar_outcome(wa, wb, lexicon)) is not None:
1002 yield familiar
1003 if len(wa) == len(wb) == 1 and (
1004 _first_half(fa, fb) or _first_half(fb, fa)):
1005 yield Outcome.HYPHENATED_PART
1006 yield Outcome.CONFLICT
1009def _initial_outcome(wa: list[str], wb: list[str], fa: str, fb: str,
1010 lexicon: Lexicon, group: str) -> Outcome | None:
1011 """Two texts' words, as `wa` and `wb`, folded whole as `fa` and
1012 `fb`: `initial` where every pair agrees and one is an initial of
1013 its name, else `initials-agree` where both are initials alone and
1014 the same ones, else `None`."""
1015 if len(wa) == len(wb):
1016 parts = [_part_agrees(x, y, lexicon, group) for x, y in zip(wa, wb)]
1017 if None not in parts and Outcome.INITIAL in parts:
1018 return Outcome.INITIAL
1019 if _initials_only(fa) and _initials_only(fb) and (
1020 _WORDS.split(fa) == _WORDS.split(fb)):
1021 return Outcome.INITIALS_AGREE
1022 return None
1025def _family_outcome(wa: list[str], wb: list[str], fa: str, fb: str,
1026 bases: tuple[str, str], lexicon: Lexicon,
1027 group: str) -> Outcome | None:
1028 """The family role's leniencies, treated as one chain, the first
1029 that holds: the bases equal once folded or listed as spellings word
1030 for word (particles set aside), one base's words each among the
1031 other's or listed as a spelling of one of them (part of the other),
1032 an initial against the family name (family initial), initials alone
1033 agreeing. A later one is not always implied by an earlier (`John S.`
1034 / `John S.-Jones` holds part of the other and family initial both),
1035 and only the first is returned (`_text_outcomes`). A listed spelling
1036 counts inside a compound surname as it does whole, in `group`
1037 (`Smyth-Jones` / `Smith` with `smyth` listed as `smith`;
1038 decisions/name-matching, 2026-11-09 #1262)."""
1039 aside = _aside_outcome(bases, lexicon, group)
1040 if aside is not None:
1041 return aside
1042 initials = _initial_outcome(wa, wb, fa, fb, lexicon, group)
1043 return (Outcome.FAMILY_INITIAL if initials is Outcome.INITIAL
1044 else initials)
1047def _aside_outcome(bases: tuple[str, str], lexicon: Lexicon,
1048 group: str) -> Outcome | None:
1049 """The two leniencies that read two bases (the texts without their
1050 particles), the first that holds: the bases equal once folded or
1051 listed as spellings word for word (particles set aside), or one
1052 base's words each among the other's or listed as a spelling of one
1053 of them (part of the other), else `None`."""
1054 spellings = _SpellingSets(lexicon, group)
1055 if bases[0] and _bases_agree(bases[0], bases[1], spellings):
1056 return Outcome.PARTICLES_ASIDE
1057 wa, wb = _base_forms(bases[0], lexicon), _base_forms(bases[1], lexicon)
1058 if wa and wb and (_covered(wa, wb, spellings) or _covered(wb, wa, spellings)):
1059 return Outcome.PART_OF_OTHER
1060 return None
1063def _pick(outcomes: Iterable[Outcome], accept: frozenset[Outcome]) -> Outcome:
1064 """The outcome a row reports: the strongest that holds and is
1065 accepted, else, where none of those holding is accepted, the
1066 strongest that holds, so an unaccepted outcome still says what the
1067 pair needed (`Jack` / `John` reads `familiar-form` under the
1068 default) and an accepted weaker one is never hidden behind it
1069 (decisions/name-matching, 2026-11-08 FAMILIAR FORMS). `outcomes`
1070 run strongest first and end in `conflict`, never accepted."""
1071 holding = iter(outcomes)
1072 strongest = next(holding)
1073 if strongest in accept:
1074 return strongest
1075 return next((o for o in holding if o in accept), strongest)
1078def _text_outcome(a: str, b: str, lexicon: Lexicon, group: str,
1079 accept: frozenset[Outcome],
1080 bases: tuple[str, str] | None = None,
1081 chain: bool = True) -> Outcome:
1082 """Two non-empty texts of one role, by `_pick` over the outcomes
1083 that hold (`_text_outcomes`)."""
1084 return _pick(_text_outcomes(a, b, lexicon, group, bases, chain), accept)
1087def _particle_half(folded: str, lexicon: Lexicon) -> bool:
1088 """A folded half of a hyphenated family word that is a listed
1089 particle (`al` in `Al-Saud`): set aside like a spaced particle, so it
1090 is neither a family-base word for `part-of-other` nor a key."""
1091 # _normalize is the lexicon's own key normalizer, so the half is
1092 # looked up as the particle set's keys are written
1093 return _normalize(folded) in lexicon._sets.particles
1096def _family_words(base: str, lexicon: Lexicon) -> list[str]:
1097 """A family base's comparable words, as written: each word, and a
1098 hyphenated one as its halves less any listed particle, an empty half
1099 dropped, a word cut at any of the pipeline's hyphens (`_HYPHENS`).
1100 Read by `part-of-other` (`_base_forms`) and by `keys`, so
1101 the two set aside the same halves. Raw and folded agree half for
1102 half: `_fold` never adds or removes a hyphen or a space, writing each
1103 hyphen as the hyphen-minus (test_match.py holds it over every
1104 assigned code point in context), so a word's
1105 halves fold to its folded halves, save that a word or half of full
1106 stops folds to empty."""
1107 words: list[str] = []
1108 for word in base.split():
1109 halves = _HYPHENS.split(word)
1110 if len(halves) > 1:
1111 words += [h for h in halves
1112 if h and not _particle_half(_fold(h), lexicon)]
1113 else:
1114 words.append(word)
1115 return words
1118def _base_forms(base: str, lexicon: Lexicon) -> list[tuple[str, str]]:
1119 """The family-base words `part-of-other` compares, each as written
1120 and folded, a word that folds to empty left out."""
1121 return [(w, f) for w in _family_words(base, lexicon) if (f := _fold(w))]
1124class _SpellingSets:
1125 """Each word's spelling set in one group (`_spellings`), built once
1126 per family comparison: the two leniencies ask a word's set against
1127 several others, and a set takes two `Lexicon._headwords` searches."""
1129 __slots__ = ("_lexicon", "_group", "_sets")
1131 def __init__(self, lexicon: Lexicon, group: str) -> None:
1132 self._lexicon, self._group = lexicon, group
1133 self._sets: dict[str, frozenset[str]] = {}
1135 def of(self, word: str) -> frozenset[str]:
1136 found = self._sets.get(word)
1137 if found is None:
1138 found = self._sets[word] = _spellings(word, self._group,
1139 self._lexicon)
1140 return found
1142 def listed(self, a: str, b: str) -> bool:
1143 """`_listed`, from the sets already built."""
1144 return not self.of(a).isdisjoint(self.of(b))
1147def _bases_agree(a: str, b: str, spellings: _SpellingSets) -> bool:
1148 """Two family bases are equal once folded, or of as many words, each
1149 pair equal once folded or listed as one spelling in the group."""
1150 if _fold(a) == _fold(b):
1151 return True
1152 wa, wb = a.split(), b.split()
1153 return len(wa) == len(wb) and all(
1154 _fold(x) == _fold(y) or spellings.listed(x, y)
1155 for x, y in zip(wa, wb))
1158def _covered(words: list[tuple[str, str]], others: list[tuple[str, str]],
1159 spellings: _SpellingSets) -> bool:
1160 """Every word of `words` is among `others` once folded, or listed as
1161 a spelling of one of them in the group; each a (written, folded)
1162 pair."""
1163 folded = {f for _, f in others}
1164 return all(f in folded or any(spellings.listed(w, o) for o, _ in others)
1165 for w, f in words)
1168def _first_half(w: str, hyphenated: str) -> bool:
1169 """The folded word `w` is the first half of the folded `hyphenated`
1170 (`hong` / `hong-yi`): the first half only, which shares the given
1171 initial a key is cut on."""
1172 parts = hyphenated.split("-")
1173 return len(parts) > 1 and "-" not in w and bool(w) and parts[0] == w
1176def _middle_outcome(a: str, b: str, lexicon: Lexicon,
1177 accept: frozenset[Outcome], group: str) -> Outcome:
1178 """The middle role word by word, in order: each aligned pair by
1179 `_text_outcome` in `group`, each word past the shorter side
1180 one-sided, the role taking the weakest; `same` where both are
1181 empty. Where one side holds more words, the shorter side's words
1182 may instead align, in order, with any of the longer side's, every
1183 aligned pair accepted and the words left over one-sided (`Beth` /
1184 `Anne Beth`); the accepted one of that alignment and the
1185 left-aligned reading is taken, or where both or neither are, the
1186 stronger, so accepting more never weakens the middle row compared
1187 on its own
1188 (decisions/name-matching, 2026-11-09 #1263). Two middle names of
1189 as many words compare pair by pair."""
1190 wa, wb = a.split(), b.split()
1191 if not wa and not wb:
1192 return Outcome.SAME
1193 outcomes = [_text_outcome(x, y, lexicon, group, accept)
1194 for x, y in zip(wa, wb)]
1195 if len(wa) == len(wb):
1196 return _weakest(outcomes)
1197 left = _one_sided(_weakest(outcomes), accept)
1198 short, long = (wa, wb) if len(wa) < len(wb) else (wb, wa)
1199 aligned = _subsequence_outcome(short, long, lexicon, group, accept)
1200 if aligned is None:
1201 return left
1202 aligned = _one_sided(aligned, accept)
1203 # an accepted reading over one that is not, else the stronger, as a
1204 # row reports the strongest outcome that holds and is accepted, else
1205 # the strongest that holds (`_pick`)
1206 if (aligned in accept) != (left in accept):
1207 return aligned if aligned in accept else left
1208 return aligned if _STRENGTH[aligned] > _STRENGTH[left] else left
1211def _one_sided(outcome: Outcome, accept: frozenset[Outcome]) -> Outcome:
1212 """`outcome` with a word left over on one side: the weaker of it and
1213 `one-side`, and `one-side` itself where the caller does not accept
1214 it, so an accepted outcome weaker than it (`familiar-form`) never
1215 hides the one-sided word."""
1216 weakest = _weakest((outcome, Outcome.ONE_SIDE))
1217 if Outcome.ONE_SIDE not in accept and weakest in accept:
1218 return Outcome.ONE_SIDE
1219 return weakest
1222def _subsequence_outcome(short: list[str], long: list[str], lexicon: Lexicon,
1223 group: str, accept: frozenset[Outcome],
1224 ) -> Outcome | None:
1225 """The strongest weakest outcome over the in-order alignments of
1226 every word of `short` with words of `long`, each aligned pair's
1227 outcome accepted; `None` where no such alignment exists. Word `i`
1228 of `short` can only align with words `i` to `i + slack` of `long`,
1229 so only that band is compared."""
1230 slack = len(long) - len(short)
1231 # best[i, j]: the strongest weakest outcome aligning short[i:] into
1232 # long[j:], or None; only cells with 0 <= j - i <= slack are read
1233 best: dict[tuple[int, int], Outcome | None] = {}
1234 for i in range(len(short), -1, -1):
1235 for j in range(i + slack, i - 1, -1):
1236 if i == len(short):
1237 best[i, j] = Outcome.SAME
1238 continue
1239 found = best[i, j + 1] if j < i + slack else None
1240 outcome = _text_outcome(short[i], long[j], lexicon, group, accept)
1241 if outcome in accept:
1242 rest = best[i + 1, j + 1]
1243 if rest is not None:
1244 here = _weakest((outcome, rest))
1245 if found is None or _STRENGTH[here] > _STRENGTH[found]:
1246 found = here
1247 best[i, j] = found
1248 return best[0, 0]
1251def _noted(a: str, b: str, lexicon: Lexicon, group: str,
1252 accept: frozenset[Outcome]) -> Outcome:
1253 """A role that never conflicts: equal by any spelling the caller
1254 accepts, else noted, so a caller who does not accept `folded` or
1255 `joined` reads `noted` rather than an outcome they refuse
1256 (decisions/name-matching, 2026-11-09 #1261)."""
1257 if not a and not b:
1258 return Outcome.SAME
1259 if a and b:
1260 full = _full_outcome(a, b, _fold(a), _fold(b), lexicon, group)
1261 if full is not None and full in accept:
1262 return full
1263 return Outcome.NOTED
1266def _suffix_outcome(left: ParsedName, right: ParsedName,
1267 lexicon: Lexicon, group: str,
1268 accept: frozenset[Outcome]) -> Outcome:
1269 """A different generation conflicts and one on one side only is its
1270 own outcome, whatever credentials either side carries; where the
1271 generations agree, the rest of the suffix (credentials, honorifics)
1272 is noted. Listed spellings are read in `group`."""
1273 if left.suffix == right.suffix:
1274 return Outcome.SAME
1275 ga, gb = left.generation, right.generation
1276 if ga and gb:
1277 full = _full_outcome(ga, gb, _fold(ga), _fold(gb), lexicon, group)
1278 if full is None:
1279 return Outcome.CONFLICT
1280 generation = full
1281 elif ga or gb:
1282 return Outcome.GENERATION_ONE_SIDE
1283 else:
1284 generation = Outcome.SAME
1285 rest = _noted(" ".join(filter(None, (left.credentials, left.honorifics))),
1286 " ".join(filter(None, (right.credentials, right.honorifics))),
1287 lexicon, group, accept)
1288 return _weakest((generation, rest))
1291#: A role's comparator: its two texts, the two names they come from, the
1292#: lexicon, the accepted outcomes and the role's own rule.
1293_Compare = Callable[
1294 [str, str, ParsedName, ParsedName, "Lexicon", frozenset[Outcome], "_RoleRule"],
1295 Outcome]
1298@dataclass(frozen=True, slots=True)
1299class _RoleRule:
1300 """How one role compares, read by the check's row for the role, the
1301 cross-role and other-reading searches and the keys (#1260). The
1302 word-by-word reading of a middle name (`_cross_texts`,
1303 `_middle_outcome`) and the familiar forms' place in the `given`
1304 group (`_text_outcomes`) stay in their helpers.
1306 - `group`: the `same_as` group its listed spellings are read in, the
1307 readings of the kind that stands there (`Lexicon._headwords`), so
1308 a given name's spelling never matches a surname (`Ma.` / `María`
1309 agree as given names; the surname Ma never matches María).
1310 - `compare`: the comparator where both sides hold the role, or, where
1311 `empty` is `None`, always: it then reads the two names whole (the
1312 suffix, which compares generations).
1313 - `empty`: the outcomes where neither side holds the role and where
1314 one side alone does; the family role's are both `conflict`, a
1315 family name being the minimum evidence.
1316 - `across`: the roles of the other name a cross-role match tries it
1317 against (`across-roles`), empty where it is never tried; the keys
1318 follow it.
1319 - `tried_into`: whether another reading may move words into it; a
1320 title never conflicts and a suffix only on its generation, so a
1321 word moved there could only empty a role that does."""
1323 group: str
1324 compare: _Compare
1325 empty: tuple[Outcome, Outcome] | None
1326 across: tuple[Role, ...] = ()
1327 tried_into: bool = True
1329 __getstate__ = _guarded_getstate
1330 __setstate__ = _guarded_setstate
1333def _compare_text(a: str, b: str, left: ParsedName, right: ParsedName,
1334 lexicon: Lexicon, accept: frozenset[Outcome],
1335 rule: _RoleRule) -> Outcome:
1336 """Two non-empty texts by `_text_outcome`."""
1337 return _text_outcome(a, b, lexicon, rule.group, accept)
1340def _compare_family(a: str, b: str, left: ParsedName, right: ParsedName,
1341 lexicon: Lexicon, accept: frozenset[Outcome],
1342 rule: _RoleRule) -> Outcome:
1343 """Two non-empty family names by `_text_outcome`, with the two family
1344 bases its leniencies compare (`particles-aside`, `part-of-other`)."""
1345 return _text_outcome(a, b, lexicon, rule.group, accept,
1346 (left.family_base, right.family_base))
1349def _compare_maiden(a: str, b: str, left: ParsedName, right: ParsedName,
1350 lexicon: Lexicon, accept: frozenset[Outcome],
1351 rule: _RoleRule) -> Outcome:
1352 """Two non-empty maiden names by `_text_outcome`, with the two maiden
1353 bases, particles set aside as `family_base` sets the family's, which
1354 the leniencies that read bases compare (`particles-aside`,
1355 `part-of-other`), outside the family chain (`chain=False`)."""
1356 return _text_outcome(a, b, lexicon, rule.group, accept,
1357 (_base_of(left, Role.MAIDEN),
1358 _base_of(right, Role.MAIDEN)), chain=False)
1361def _compare_middle(a: str, b: str, left: ParsedName, right: ParsedName,
1362 lexicon: Lexicon, accept: frozenset[Outcome],
1363 rule: _RoleRule) -> Outcome:
1364 return _middle_outcome(a, b, lexicon, accept, rule.group)
1367def _compare_noted(a: str, b: str, left: ParsedName, right: ParsedName,
1368 lexicon: Lexicon, accept: frozenset[Outcome],
1369 rule: _RoleRule) -> Outcome:
1370 return _noted(a, b, lexicon, rule.group, accept)
1373def _compare_suffix(a: str, b: str, left: ParsedName, right: ParsedName,
1374 lexicon: Lexicon, accept: frozenset[Outcome],
1375 rule: _RoleRule) -> Outcome:
1376 return _suffix_outcome(left, right, lexicon, rule.group, accept)
1379_EMPTY = (Outcome.SAME, Outcome.ONE_SIDE)
1381#: Each role's rule (`_RoleRule`), one row per `Role`.
1382_RULES: Mapping[Role, _RoleRule] = {
1383 Role.TITLE: _RoleRule("title", _compare_noted,
1384 (Outcome.SAME, Outcome.NOTED), tried_into=False),
1385 Role.GIVEN: _RoleRule("given", _compare_text,
1386 (Outcome.SAME, Outcome.GIVEN_ONE_SIDE),
1387 across=(Role.MIDDLE, Role.NICKNAME)),
1388 Role.MIDDLE: _RoleRule("given", _compare_middle, _EMPTY),
1389 Role.FAMILY: _RoleRule("family", _compare_family,
1390 (Outcome.CONFLICT, Outcome.CONFLICT),
1391 across=(Role.MAIDEN,)),
1392 Role.SUFFIX: _RoleRule("suffix", _compare_suffix, None, tried_into=False),
1393 Role.NICKNAME: _RoleRule("given", _compare_text, _EMPTY),
1394 Role.MAIDEN: _RoleRule("family", _compare_maiden, _EMPTY),
1395}
1398def _role_outcome(rule: _RoleRule, a: str, b: str, left: ParsedName,
1399 right: ParsedName, lexicon: Lexicon,
1400 accept: frozenset[Outcome]) -> Outcome:
1401 """Two texts of one role, `a` from `left` and `b` from `right`, by
1402 the role's rule: its comparator where both hold text or where it
1403 reads the names whole, else its outcome for one side or none."""
1404 if rule.empty is None or (a and b):
1405 return rule.compare(a, b, left, right, lexicon, accept, rule)
1406 both_empty, one_side = rule.empty
1407 return one_side if a or b else both_empty
1410def _compare_role(role: Role, left: ParsedName, right: ParsedName,
1411 lexicon: Lexicon, accept: frozenset[Outcome]) -> RoleMatch:
1412 """One role's `RoleMatch`, decided by its `_RULES` entry."""
1413 a, b = _role_text(left, role), _role_text(right, role)
1414 outcome = _role_outcome(_RULES[role], a, b, left, right, lexicon, accept)
1415 more = Side.NEITHER if outcome is Outcome.CONFLICT else _more(a, b)
1416 # the table shows each field as written; `_role_text` is the reading
1417 return RoleMatch(role, outcome, getattr(left, role), getattr(right, role),
1418 more)