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

561 statements  

1"""Name matching: the settings object, the result types and the check. 

2 

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. 

15 

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 

30 

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 

37 

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) 

51 

52if TYPE_CHECKING: 

53 from nameparser._parser import Parser 

54 

55 

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

67 

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" 

85 

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] 

93 

94 

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

98 

99 

100class Side(StrEnum): 

101 """Which side of a comparison carried more of the role's text.""" 

102 

103 LEFT = "left" 

104 RIGHT = "right" 

105 NEITHER = "neither" 

106 

107 

108_MIRROR = {Side.LEFT: Side.RIGHT, Side.RIGHT: Side.LEFT, Side.NEITHER: Side.NEITHER} 

109 

110 

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

117 

118 role: Role 

119 outcome: Outcome 

120 left: str 

121 right: str 

122 more: Side 

123 

124 __getstate__ = _guarded_getstate 

125 __setstate__ = _guarded_setstate 

126 

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

131 

132 

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

138 

139 left_role: Role 

140 right_role: Role 

141 outcome: Outcome 

142 left: str 

143 right: str 

144 

145 __getstate__ = _guarded_getstate 

146 __setstate__ = _guarded_setstate 

147 

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) 

152 

153 

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. 

165 

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

168 

169 consistent: bool 

170 roles: tuple[RoleMatch, ...] 

171 across: tuple[CrossRoleMatch, ...] = () 

172 left_alternative: Ambiguity | None = None 

173 right_alternative: Ambiguity | None = None 

174 

175 __getstate__ = _guarded_getstate 

176 __setstate__ = _guarded_setstate 

177 

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

196 

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

201 

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) 

206 

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) 

216 

217 

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

225 

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

236 

237 

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

260 

261 

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. 

267 

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. 

273 

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

280 

281 parser: Parser 

282 accept: frozenset[Outcome] = DEFAULT_ACCEPT 

283 other_readings: bool = False 

284 

285 __getstate__ = _guarded_getstate 

286 __setstate__ = _guarded_setstate 

287 

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

314 

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

327 

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. 

337 

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. 

349 

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) 

375 

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. 

403 

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

421 

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) 

466 

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] 

482 

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] 

496 

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 

531 

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 

559 

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) 

620 

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

630 

631 

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

653 

654 

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

659 

660 

661# -- the check ------------------------------------------------------------- 

662 

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] 

681 

682 

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) 

689 

690 

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) 

697 

698 

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 [] 

706 

707 

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) 

733 

734 

735_RUN_ROLES = frozenset({Role.GIVEN, Role.MIDDLE}) 

736 

737 

738 

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

755 

756 

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

765 

766 

767def _weakest(outcomes: Iterable[Outcome]) -> Outcome: 

768 return min(outcomes, key=_STRENGTH.__getitem__) 

769 

770 

771def _letters(text: str) -> int: 

772 return sum(c.isalnum() for c in _fold(text)) 

773 

774 

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 

780 

781 

782def _is_initial(folded: str) -> bool: 

783 return len(folded) == 1 and folded.isalpha() and not _initialless(folded) 

784 

785 

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} 

800 

801 

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

810 

811 

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

817 

818 

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 

838 

839 

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} 

847 

848 

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) 

864 

865 

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 

881 

882 

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 

893 

894 

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] 

899 

900 

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) 

907 

908 

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) 

912 

913 

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 

934 

935 

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

945 

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 

1007 

1008 

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 

1023 

1024 

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) 

1045 

1046 

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 

1061 

1062 

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) 

1076 

1077 

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) 

1085 

1086 

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 

1094 

1095 

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 

1116 

1117 

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

1122 

1123 

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

1128 

1129 __slots__ = ("_lexicon", "_group", "_sets") 

1130 

1131 def __init__(self, lexicon: Lexicon, group: str) -> None: 

1132 self._lexicon, self._group = lexicon, group 

1133 self._sets: dict[str, frozenset[str]] = {} 

1134 

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 

1141 

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

1145 

1146 

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

1156 

1157 

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) 

1166 

1167 

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 

1174 

1175 

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 

1209 

1210 

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 

1220 

1221 

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] 

1249 

1250 

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 

1264 

1265 

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

1289 

1290 

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] 

1296 

1297 

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. 

1305 

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

1322 

1323 group: str 

1324 compare: _Compare 

1325 empty: tuple[Outcome, Outcome] | None 

1326 across: tuple[Role, ...] = () 

1327 tried_into: bool = True 

1328 

1329 __getstate__ = _guarded_getstate 

1330 __setstate__ = _guarded_setstate 

1331 

1332 

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) 

1338 

1339 

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

1347 

1348 

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) 

1359 

1360 

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) 

1365 

1366 

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) 

1371 

1372 

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) 

1377 

1378 

1379_EMPTY = (Outcome.SAME, Outcome.ONE_SIDE) 

1380 

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} 

1396 

1397 

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 

1408 

1409 

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)