Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/nameparser/_pipeline/_assign.py: 7%

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

463 statements  

1"""Stage: assign. 

2 

3Consumes: pieces + piece_tags (grouped), segments, structure, tokens, 

4one_case, and tail_reads -- rules/S2's trailing run as group read it 

5once, after P3's joins and before the particle chain (#614), which the 

6main segment's placement takes rather than peeling again, save where 

7group's joins left no name word in front of it; and interpunct_offsets, 

8whose 间隔号 keeps a transcription in its declared order (rules/W4). 

9Produces: tokens with roles set on every main-stream token, and the 

10folded-middle and attached tags on the particles P6's attachment joins 

11to the family after a family comma (#613; the attached tag, 3.0's 4c, 

12tells rules/R9 the attachment from O3's fold); and order, the order 

13the positional read used, None where none happened (ParseState.order). 

14Reads: Policy.name_order (#270), Policy.lone_name_word (rules/O5, 

15the field a lone name word takes, #1111), Policy.initials_as_family 

16(rules/O4's order report stands down under it, #1254), is_suffix_lenient on the trailing 

17piece of a two-part comma name, and Policy.script_orders (#271, which 

18overrides it when every name piece is written wholly in one script, or 

19in the Han/Hiragana/Katakana repertoire the #272 kana license shares 

20across pieces); token/piece tags; Lexicon only through tags already 

21applied by classify (plus the leading-title period rule), and 

22Lexicon.given_name_titles, which an ambiguity's alternative asks for 

23rules/P5's given-name-title licence; and Lexicon.title_phrases, titles 

24and titles_ambiguous through _vocab.in_a_title_phrase, for rules/H9's 

25TITLE_OR_NAME report on a contested word opening the name. 

26 

27Implements rules H2, H4, H5, H6, H7, H8, N3, O4, O5, S10, W4, and P6 after a family comma, 

28of docs/design/rules/, 

29each cited at its code below. Ports v1's assignment loops. 

30NO_COMMA (per name_order): 

31leading title pieces chain while no given-position name has been seen 

32(a title needs a following piece, unless the whole name is one title); 

33then positional assignment per name_order with the trailing-suffix 

34rule: the piece from which everything after is a strict suffix is the 

35last name-position piece, the rest are suffixes. Behind that peel a 

36trailing run of the title words H5's chain takes 

37(`_pieces.trailing_titles`) chains into the title from 

38the end, leaving one name piece standing; where the run TAKES 

39something the peel was only provisional and runs again over the pieces 

40with the titled ones spliced out, the two alternating until the run 

41takes nothing, so a trailing title is transparent to the suffix 

42reading however many titles are written (_pieces.tail_reading). Where 

43the run takes nothing -- almost every name -- the first peel is the 

44only one and its answer stands. 

45The v1 single-name+nickname rule lives here (decisions/N3): a 

46nonempty nickname beside exactly one piece in total puts that piece 

47in FAMILY. 

48FAMILY_COMMA: segment 0 FAMILY but for a leading run of listed titles 

49(rules/H6, #701; v1 kept them in the family) and the post-nominals 

50behind its first family piece (SUFFIX, 'Smith Jr., John'). A part after the 

51comma that holds no name word, or that a credential opens (#603), is 

52not read here: group's head binds its SUFFIX and TITLE roles before 

53any join (`_comma.decide`, #613), making the comma a suffix comma 

54behind a whole name ('John Smith, Dr.', 'John Smith, PhD Jones') and 

55leaving the family comma behind one name word ('Smith, Jr.', 'Smith, 

56PhD Jones'), where segment 1 then reaches this stage with no pieces. 

57A segment 1 that does reach it gets leading titles, then the same 

58trailing title run over the pieces this segment does not read as 

59suffixes ('Smith, John Prof.'), then given, then middles with those 

60suffix-reading pieces to suffix -- one predicate for both -- and 

61#602's run after the given word ('Smith, John PhD Jones'); segments 2+ 

62are suffixes but for their title words (#603), lenient -- segment 

63already flagged the ones that are neither COMMA_STRUCTURE. 

64SUFFIX_COMMA: segment 0 as NO_COMMA; segment 1 arrives bound by group, 

65and segments 2+ are read as after a family comma. 

66Emits PARTICLE_OR_GIVEN when the leading name piece is a lone 

67particles_ambiguous token with more pieces following ("Van Johnson", 

68and since #367 "Dr. Van Johnson" too, a title no longer displacing the 

69particle out of that position) -- whatever role name_order assigns, 

70save a lowercase one in a mixed-case name under a family-first order, 

71which P1's fold takes (rules/P11, #670) -- 

72and, since #613, at P6's attachment after a family comma where the run 

73holds an ambiguous particle ("Beethoven, Ludwig van"). 

74Emits SUFFIX_OR_NAME at these sites: the trailing roman numeral, taken 

75or declined (since #718 for want of words to spare, since #752 for its 

76Title case), a numeral ending the family part before a family comma, 

77one of two or more letters ending a given part's walk, a bare I or V 

78read as the generation ending that walk behind a name word (since 

79#877), each 

80ambiguous acronym the trailing peel had to resolve, the bare-suffix 

81carve-out where an input that is nothing but post-nominal vocabulary 

82gets its first word made into the name (H4's suffix half, #491), 

83-- since #289 -- the FAMILY-COMMA path's own read of the first 

84post-comma piece as the given name, -- since #531 -- the class member 

85ENDING that path's given part, which the first-piece emitter could 

86never reach, -- since #602 -- each name word the credential run 

87absorbs, in the no-comma name and in the given part after a family 

88comma, and -- since #613 -- P6's attachment after a family comma, 

89where it declines a post-nominal reading or stands down inside a 

90credential run. 

91Further emitters of the same kind live in `_comma.py` (called at 

92group's head: the comma's own decision, #544's anchored members and 

93#603's absorbed words) and `_group.py`; they are not assign's and are 

94not counted here. And 

95at the one site that places a LONE name word, GIVEN_OR_FAMILY for the 

96field the convention picked (O5, #449) and TITLE_OR_NAME for the two 

97shapes where the doubt is whether a word is a title instead (H4 and 

98H7, #491): the peel leaving one title-vocabulary word standing, and a 

99joined unit carrying title vocabulary. 

100""" 

101from __future__ import annotations 

102 

103import unicodedata 

104 

105from collections.abc import Sequence, Set 

106from typing import NamedTuple 

107 

108from nameparser._lexicon import Lexicon, _run_addresses_by_given 

109from nameparser._pipeline._vocab import ( 

110 _ROMAN, effective_script, in_a_title_phrase, in_initialless_script, 

111 is_initial_shaped, 

112 TITLE_READING, is_listed_numeral, particle_reading, 

113 is_suffix_lenient, name_reading, suffix_reading, 

114 numeral_leans_to_the_name, numeral_settled_by_case, 

115 reads_as_a_reported_numeral, reads_as_generation, resolve_script_set, 

116) 

117from nameparser._pipeline._pieces import ( 

118 split_credential_report, 

119 anchor_in_reach, credential_at_the_given_slot, given_slot_anchors, 

120 _NOT_A_RUN_START, has_name_content, is_lone_never_given_particle, 

121 is_lowercase_ambiguous_particle, 

122 Peel, TailRead, is_prefix_piece, is_suffix_piece, is_title_piece, 

123 leading_titles, 

124 particle_tail, listed_lean, peel_walk, settled_by_case, 

125 starts_a_credential_run, starts_the_run_here, 

126 tail_reading, trailing_titles, 

127) 

128from nameparser._pipeline._state import ( 

129 AMBIGUOUS_ACRONYM_TAG, ATTACHED_TAG, DOTTED_TITLE_TAG, NAME_ROLES, TITLE_AMBIGUOUS_TAG, ParseState, 

130 Beside, Deferred, PendingAmbiguity, PendingReading, Structure, 

131 WorkToken, _AMBIGUOUS_CREDENTIAL_TAGS, _NEVER_FLIPPED, copy_with, 

132) 

133from nameparser._policy import Policy, Script 

134from nameparser._types import (FOLDED_TAG, SHAPE_ACRONYM_TAG, AmbiguityKind, 

135 ReadingKind, ReadingSource, Role) 

136 

137def _set_roles(tokens: list[WorkToken], piece: tuple[int, ...], 

138 role: Role) -> None: 

139 for i in piece: 

140 tokens[i] = copy_with(tokens[i], role=role) 

141 

142 

143def _name_pieces(state: ParseState, tokens: Sequence[WorkToken], 

144 seg_idx: int) -> list[tuple[int, ...]]: 

145 """The pieces of segment `seg_idx` the parse read as name words.""" 

146 return [q for q in state.pieces[seg_idx] 

147 if tokens[q[0]].role in NAME_ROLES] 

148 

149 

150def _slot_field(state: ParseState, seg_idx: int, pos: int, count: int, 

151 order: tuple[Role, Role, Role] | None) -> Role: 

152 """The field position `pos` of `count` name words takes in segment 

153 `seg_idx`, every other word read as the parse read it: the family 

154 before a family comma, the given part's given or middle name after 

155 one, and otherwise the order the main segment's read set. 

156 

157 `count` is two or more wherever no family comma decides, so rules/O5's 

158 lone-word field is never asked here: `as_a_name` counts one more than 

159 the name pieces, of which assign always places one (its lone-piece 

160 branches index the first, so a parse with none would raise there); a split 

161 pair stands behind a name word H4's carve-out keeps; and 

162 `_resolve_deferred` counts a chained word one more than its name's, 

163 and reads a join that would leave the name one word as no join 

164 (rules/P3) before it asks.""" 

165 if state.structure is Structure.FAMILY_COMMA: 

166 return (Role.FAMILY if seg_idx == 0 

167 else Role.GIVEN if pos == 0 else Role.MIDDLE) 

168 assert order is not None # the main segment's read sets it 

169 return _name_positions(order, count)[pos] 

170 

171 

172def _name_alt(token: WorkToken, role: Role, 

173 front: Sequence[int] | None, tokens: Sequence[WorkToken], 

174 first: bool, licensed: bool) -> PendingReading: 

175 """The name reading of a word read as something else, landing in 

176 `role` unless the name piece `front` it stands behind would take it 

177 in, so that it lands where that piece lands: a particle chain runs 

178 to the end of the part's name words (rules/P2), and a lone particle 

179 stands unchained only for want of a word behind it, unless it opens 

180 the part, where it never chains (rules/P4); and a bound given name 

181 opening the part joins the word after it where it is `licensed`, a 

182 family comma having fixed the family or a given-name title standing 

183 ahead (rules/P5), 

184 since the word would otherwise be the family word the join 

185 reserves; and a never-given particle opening the part folds it into 

186 the family (rules/P1). The front's field is named at 

187 assemble, where P6 may since have attached it to the family, which 

188 it would not have done with a word behind it (`Beside`).""" 

189 alt = name_reading(token, role) 

190 if front is None: 

191 return alt 

192 tags = tokens[front[0]].tags 

193 # rules/P1's fold: a never-given particle opening the part takes the 

194 # word behind it into the family, whatever the order and the part 

195 if first and is_lone_never_given_particle(front, tokens): 

196 return copy_with(alt, role=Role.FAMILY) 

197 # a piece opening the part with a bound given name is P5's join, 

198 # not a chain, though `abu` is a particle too: the word stands apart 

199 bound = first and "vocab:bound-given" in tags 

200 if ("particle" in tags and (len(front) > 1 or not first) and not bound 

201 or licensed and bound and len(front) == 1): 

202 # a front P6 has already attached held `role`'s field before it; 

203 # `last` names the front's last word, which rules/P10 leaves the 

204 # family where it reads the chain's opening phrase as a middle 

205 # name (decisions/P10, #704) 

206 return copy_with(alt, role=Beside( 

207 front[0], role if FOLDED_TAG in tags else tokens[front[0]].role, 

208 last=front[-1])) 

209 return alt 

210 

211 

212def _absorbed(piece: Sequence[int], tokens: Sequence[WorkToken], 

213 alt: PendingReading | None) -> PendingAmbiguity: 

214 """rules/S8's report for a name word #602's run absorbs, one 

215 spelling for the no-comma and the given-part run. `alt` is its 

216 name reading, whose field the caller's positional read knows (None 

217 where the caller fills it in once it does).""" 

218 text = " ".join(tokens[i].text for i in piece) 

219 return PendingAmbiguity( 

220 AmbiguityKind.SUFFIX_OR_NAME, 

221 f"{text!r} follows a credential, so it reads as part of the " 

222 f"suffix run; it may be a name word", tuple(piece), 

223 alternative=alt) 

224 

225 

226#: Tags that say the word's own reading was claimed before position 

227#: could speak, so O5's convention decided nothing. Built from M4's 

228#: `_NEVER_FLIPPED` pair rather than respelling it: the two sets answer 

229#: different questions -- may M4 retag the word, and did anything 

230#: decide the field -- and share the pair because a bound given name, 

231#: or a word written as an initial, answers both. The 

232#: third, `particle`, is here alone because a lone particle's reading 

233#: is P4's. None is a predicate this emitter owns: they are read off 

234#: the tags classify already recorded (mechanisms.md#TWO-LAYER-ASSIGN). 

235_WORD_ALREADY_CLAIMED = _NEVER_FLIPPED | frozenset({"particle"}) 

236 

237 

238# rules/H2: "an abbreviation opening the part of the name that 

239# carries the given name — the whole name, or the part after a 

240# family comma — reads as a title even when unlisted" -- the count is 

241# _pieces.leading_titles since #424 (the chain's leading position is 

242# read off the same run, _pieces.chain_lead, #624); the roles are set 

243# here. 

244def _peel_leading_titles(pieces: tuple[tuple[int, ...], ...], 

245 ptags: tuple[frozenset[str], ...], 

246 tokens: list[WorkToken], 

247 ambiguities: list[PendingAmbiguity] | None, 

248 lexicon: Lexicon) -> int: 

249 """Assign TITLE to the leading title pieces and return the first 

250 non-title index. `ambiguities` is None where a family comma has 

251 already marked the part as the given name, so no fork is left to 

252 report.""" 

253 n = leading_titles(pieces, ptags, tokens) 

254 for k in range(n): 

255 _set_roles(tokens, pieces[k], Role.TITLE) 

256 # rules/H9: "read as a name word where it is the first name word 

257 # after any titles" -- the peel stops at it because classify read it 

258 # out of the title, so it is the first piece the peel leaves 

259 # ('Dean Smith', 'Mr Dean Smith'); and rules/H9: "a word no listed 

260 # phrase holds is a title only behind a style or a modifier, or 

261 # before `of`", so standing alone it reports nothing ('Grace Smith') 

262 if (ambiguities is not None and n < len(pieces) and len(pieces[n]) == 1 

263 and TITLE_AMBIGUOUS_TAG in tokens[pieces[n][0]].tags 

264 and "vocab:title" not in tokens[pieces[n][0]].tags 

265 and in_a_title_phrase(tokens[pieces[n][0]].text, lexicon)): 

266 i = pieces[n][0] 

267 ambiguities.append(PendingAmbiguity( 

268 AmbiguityKind.TITLE_OR_NAME, 

269 f"{tokens[i].text!r} is a title that may also be a given name, " 

270 f"and stands in no title phrase; read as ", (i,), 

271 field_tail=" rather than a title", alternative=TITLE_READING)) 

272 return n 

273 

274 

275class EffectiveOrder(NamedTuple): 

276 """What _effective_order made of a name's scripts. `order` is the 

277 order the positional read uses; `by_script` says a script_orders 

278 entry RESOLVED it, which is not the same as `order` happening to 

279 equal the declared name_order -- under a declared family-first 

280 order a Han name's W4 entry and the declaration agree, and only 

281 this flag distinguishes the script rule DECIDING the reading from 

282 the caller's declaration standing unopposed. O5's convention 

283 report reads it (#449).""" 

284 

285 order: tuple[Role, Role, Role] 

286 by_script: bool 

287 

288 

289# rules/W4: "a name written wholly in one East Asian script, or in 

290# the kana-licensed Japanese repertoire, reads family-first whatever 

291# order the caller declared; a wholly-katakana name keeps the declared 

292# order" (history: decisions/W4) 

293def _effective_order(policy: Policy, 

294 pieces: Sequence[tuple[int, ...]], 

295 name_pieces: Sequence[int], 

296 tokens: list[WorkToken], 

297 *, dot_divided: bool) -> EffectiveOrder: 

298 """script_orders resolution (#271): when every name piece is 

299 written wholly in ONE script that has an entry, that script's 

300 order governs the positional read; anything else -- Latin, mixed 

301 scripts, no entry -- falls back to name_order. A 间隔号-divided 

302 name (`dot_divided`, #298) suppresses the whole lookup first: the 

303 dot marks a transcription -- playing the role pure katakana plays 

304 in the kana license, orthography naming the convention -- so the 

305 license yields to name_order. Piece-level, after 

306 title/suffix peeling: 'Dr. 毛泽东' is a wholly-Han NAME under a 

307 Latin title. Kana-licensed tokens (高橋みなみ, #272) resolve to 

308 HIRAGANA the same way a wholly-Han or wholly-Hangul token resolves 

309 to its own script -- and so does a kana-licensed NAME split across 

310 separately single-script PIECES ('高橋 みなみ', Han piece plus 

311 Hiragana piece): resolve_script_set generalizes the license from 

312 one token's characters to the whole found-script set below, which 

313 is why Han+Hangul ('毛 김') still declines even though both 

314 individually read family-first -- the license is specific to the 

315 Han/Hiragana/Katakana repertoire, not "the entries happen to 

316 agree". 

317 

318 Naming note, since the two are easy to conflate: THIS function 

319 resolves the ORDER for a whole name; `_vocab.effective_script` 

320 resolves the SCRIPT for a single token. This function calls that 

321 one per token below. 

322 

323 Takes the segment's pieces and WHICH of them the name kept, the 

324 shape every piece-layer predicate takes: a caller holding those 

325 indices had to build a second list of the same pieces to hand 

326 over otherwise, which on 3.11 is a comprehension frame on every 

327 parse (decisions/parse-cost). 

328 

329 Returns an EffectiveOrder: the order triple, and `by_script` set only on 

330 the one path where an entry answered. Every fallback below is a 

331 script rule DECLINING, and reports it as such. 

332 """ 

333 declared = EffectiveOrder(policy.name_order, by_script=False) 

334 # #298 transcription marker -- see the docstring; codepoint-scoped 

335 # (only U+00B7 records; decisions/T3) 

336 if dot_divided: 

337 return declared 

338 if not policy.script_orders: 

339 return declared 

340 # Collect every token's script rather than comparing pairwise as 

341 # tokens are seen: the kana license needs the WHOLE set (a Han 

342 # piece and a Hiragana piece only license together, never one at a 

343 # time), so resolution is deferred to resolve_script_set below. 

344 found: set[Script] = set() 

345 for piece_idx in name_pieces: 

346 for i in pieces[piece_idx]: 

347 script = effective_script(tokens[i].text) 

348 if script is None: 

349 # Latin, mixed, or a script with no entry: never a key 

350 return declared 

351 found.add(script) 

352 resolved = resolve_script_set(found) 

353 if resolved is None: 

354 # e.g. Han+Hangul: two scripts, neither the kana license's 

355 # Han/Hiragana/Katakana repertoire -- no single tradition 

356 return declared 

357 for script, order in policy.script_orders: 

358 if script is resolved: 

359 return EffectiveOrder(order, by_script=True) 

360 # the resolved script has no entry: the declaration stands, and 

361 # nothing about the writing system decided the reading 

362 return declared 

363 

364 

365# rules/O4: "words no vocabulary has claimed read by position. In 

366# the default given-first order the first name word is the given name, 

367# the last is the family name, and everything between is middle names" 

368def _name_positions(order: tuple[Role, Role, Role], 

369 count: int) -> list[Role]: 

370 """Roles for `count` name pieces (titles/suffixes already peeled), 

371 per name_order. GIVEN_FIRST: given, middles..., family. 

372 FAMILY_FIRST: family, given, middles... FAMILY_FIRST_GIVEN_LAST: 

373 family, middles..., given. One piece takes order[0], which 

374 `_assign_main`, placing a lone name word, overrides with rules/O5's 

375 field (every other caller lays out two or more, or slices the first 

376 slot off); two pieces take order[0] and the other primary.""" 

377 first, second = order[0], order[1] 

378 if count == 1: 

379 return [first] 

380 if first is Role.GIVEN: # GIVEN_FIRST 

381 return ([Role.GIVEN] + [Role.MIDDLE] * (count - 2) 

382 + [Role.FAMILY]) 

383 if second is Role.GIVEN: # FAMILY_FIRST 

384 return ([Role.FAMILY, Role.GIVEN] 

385 + [Role.MIDDLE] * (count - 2)) 

386 return ([Role.FAMILY] + [Role.MIDDLE] * (count - 2) # F_F_GIVEN_LAST 

387 + [Role.GIVEN]) 

388 

389 

390def _placed(read: TailRead, rest: list[int], 

391 pieces: tuple[tuple[int, ...], ...], 

392 ) -> tuple[list[int], tuple[int, ...], Peel]: 

393 """`tail_reading`'s three answers, taken off the run group read at 

394 its head (#614) rather than read again over the joined pieces: the 

395 walk with the name pieces first and the run's suffixes after them, 

396 the pieces the H5 chain took, and the read's peel with `names` and 

397 `run_titles` renumbered onto these pieces. Group split the run off 

398 before its joins and put it back after them unchanged, so each of 

399 its pieces is whole here and a membership test on its first token 

400 places it.""" 

401 names: list[int] = [] 

402 suffixes: list[int] = [] 

403 titled: list[int] = [] 

404 run_titles: list[int] = [] 

405 for k in rest: 

406 first = pieces[k][0] 

407 if first not in read.tail: 

408 names.append(k) 

409 elif first in read.titles: 

410 titled.append(k) 

411 else: 

412 suffixes.append(k) 

413 if first in read.run_titles: 

414 run_titles.append(k) 

415 peel = read.peel 

416 count = len(names) 

417 names.extend(suffixes) 

418 # built, not `_replace`d: that is two frames on every read 

419 return names, tuple(titled), Peel( 

420 count, peel.numerals, peel.picks, peel.anchors, tuple(run_titles), 

421 peel.absorbed) 

422 

423 

424def _assign_main(seg_idx: int, state: ParseState, 

425 tokens: list[WorkToken], 

426 ambiguities: list[PendingAmbiguity], 

427 ) -> tuple[Role, Role, Role] | None: 

428 """Returns the order the positional read used, for ParseState.order 

429 -- None on every path that returns before resolving one.""" 

430 pieces = state.pieces[seg_idx] 

431 ptags = state.piece_tags[seg_idx] 

432 n = _peel_leading_titles(pieces, ptags, tokens, ambiguities, 

433 state.lexicon) 

434 if n == len(pieces): 

435 return None 

436 # group-flagged suffix pieces (the ph-d merge) are suffixes at ANY 

437 # position -- v1's fix_phd extracted the credential from the string 

438 # before parsing, so position never mattered (PR review I3). 

439 # Walked rather than collected first: the list was never read 

440 # again, and on 3.11 a comprehension is a frame of its own, which 

441 # is one frame back against the one the H5 reading below costs. A 

442 # 3.11 FACT and not a portable one: PEP 709 inlines comprehensions 

443 # from 3.12, where this tree's reference parse costs 395 written 

444 # either way (measured 2026-09-09, against 416 and 417 on 3.11). 

445 # 3.11 is the interpreter the band is quoted for, so 3.11 is what 

446 # the shape is chosen on (decisions/parse-cost). 

447 for k in range(n, len(pieces)): 

448 if "suffix" in ptags[k]: 

449 _set_roles(tokens, pieces[k], Role.SUFFIX) 

450 rest = peel_walk(n, ptags) 

451 if not rest: 

452 return None 

453 # rules/N3: "a name that is only a nickname and one name word 

454 # reads that word as the family name" (history: decisions/N3) 

455 # -- v1's p_len == 1 counted 

456 # the WHOLE segment before any title peeling -- 'Xyz. (Bud) Smith' 

457 # has two pieces, so the title peel wins and this rule declines; 

458 # H1 then names Smith the family (rules/N3's title bullet) 

459 # A suffix makes two pieces as a title does: that shape is N3's 

460 # suffix clause, at O5's site below. 

461 # The nickname scan sits LAST in the test: it is a generator, which 

462 # every interpreter resumes once per token (seven call events on 

463 # the reference name), and only a one-piece segment ever reads its 

464 # answer. Hoisting it to the top of the read, where it once stood, 

465 # is what put 3.12-3.15 one call over the band (decisions/parse-cost). 

466 # Behind `state.extracted`, as at N3's suffix clause below: a 

467 # nickname is a clause extract set aside. 

468 if (len(pieces) == 1 and len(rest) == 1 and state.extracted 

469 and any(t.role is Role.NICKNAME for t in tokens)): 

470 _set_roles(tokens, pieces[rest[0]], Role.FAMILY) 

471 return None 

472 # rules/S2's trailing peel and rules/H5's title chain, read 

473 # together to their fixed point by _pieces.tail_reading -- one 

474 # function since the /simplify round, shared with the bound-given 

475 # reserve (P5), which must count the name words this leaves. 

476 # 

477 # rules/H5: "successive single words that wear the abbreviation 

478 # shape and are title vocabulary chain into the title from the end, 

479 # leaving one name word standing" -- the titles are set BEFORE 

480 # _name_positions, so the shortened list is what the positional 

481 # read and the script test both see (a trailing Latin title must 

482 # not make a wholly-CJK name look mixed-script, the same reason the 

483 # leading peel runs first). Read ahead of the bare-suffix carve-out 

484 # because with no name piece left there is nothing for the chain to 

485 # read: its floor keeps 0 pieces of an empty list, so the branch 

486 # below is reached exactly as before. 

487 # 

488 # Every bare ambiguous acronym the FINAL peel had to resolve is one 

489 # coin-flip each, in either direction, so the report collects 

490 # rather than overwrites. Deferred to after assignment because what 

491 # a pick is declined as depends on the role it took, and which role 

492 # "not peeled" means depends on name_order; the field itself is 

493 # worded by assemble (#626). (The roman-numeral fork's TAKEN branch 

494 # needs no such deferral and is reported here; its declined branch 

495 # is a pick, #718, and is reported with the others.) 

496 # 

497 # Read once in group, after P3's connective joins and ahead of the 

498 # particle chain, on a main segment of three or more pieces whose 

499 # run is one block at its end (#614): the run is placed off that 

500 # reading. Where the read left name pieces and 

501 # group's joins then left none past the leading titles -- a join 

502 # that made the name a title ('Freiherr von Berg Dr. and Ed. 

503 # Prof.') -- the reading is taken here over what stands, as it was 

504 # taken everywhere before #614, so the name keeps a word. The same 

505 # procedure and not always the same answer: the run group read 

506 # split off was kept out of group's joins, so what stands differs 

507 # from what stood before #614 (that name read family 'von Berg Dr. 

508 # and Ed. Prof.' then). 

509 read = (state.tail_reads[seg_idx] 

510 if seg_idx < len(state.tail_reads) else None) 

511 if read is not None: 

512 placed = _placed(read, rest, pieces) 

513 if read.peel.names and not placed[2].names: 

514 read = None 

515 else: 

516 rest, titled_tail, peeled = placed 

517 if read is None: 

518 rest, titled_tail, peeled = tail_reading(rest, pieces, ptags, tokens, 

519 state.one_case) 

520 for piece_idx in titled_tail: 

521 _set_roles(tokens, pieces[piece_idx], Role.TITLE) 

522 # rules/S8: "except a title word, which reads as a title" -- the 

523 # credential run (#602) is placed here, so a name word it absorbed 

524 # is reported here too 

525 for piece_idx in peeled.run_titles: 

526 _set_roles(tokens, pieces[piece_idx], Role.TITLE) 

527 # the name reading of a word the run or the numeral fork took lands 

528 # where one more name word after the name's would: its field is 

529 # filled in below, once the positional read has an order 

530 trailing_alts = len(ambiguities) 

531 for piece in peeled.absorbed: 

532 ambiguities.append(_absorbed(piece, tokens, None)) 

533 for numeral in peeled.numerals: 

534 # a single letter is a name part unless it happens to be a 

535 # roman numeral -- and V/X/I are ordinary middle initials, so 

536 # taking it as a suffix is a call, not a fact; a numeral read 

537 # by its shape alone is in no wordlist, and may be a name word 

538 text = tokens[numeral[0]].text 

539 ambiguities.append(PendingAmbiguity( 

540 AmbiguityKind.SUFFIX_OR_NAME, 

541 f"{text!r} is a roman numeral, so it reads as a " 

542 f"generational suffix; " 

543 + ("any other single letter there would be a middle initial" 

544 if len(text.rstrip(".")) == 1 

545 else "it may be a name word"), 

546 numeral)) 

547 name_pieces, suffix_pieces = rest[:peeled.names], rest[peeled.names:] 

548 if peeled.run_titles: 

549 suffix_pieces = [q for q in suffix_pieces 

550 if q not in peeled.run_titles] 

551 if peeled.names == 0: 

552 # everything suffix-shaped after titles: first one is the name 

553 name_pieces, suffix_pieces = suffix_pieces[:1], suffix_pieces[1:] 

554 # AFTER the whole tail reading, and load-bearing: the script test 

555 # sees the NAME pieces only, so a Latin title or suffix ('Dr. 毛 

556 # 泽东', '毛 泽东, PhD') cannot make a wholly-CJK name look 

557 # mixed-script. 

558 resolved = _effective_order(state.policy, pieces, name_pieces, tokens, 

559 dot_divided=bool(state.interpunct_offsets)) 

560 order = resolved.order 

561 # rules/O5: "a name of one name word that nothing else has 

562 # decided reads that word as the field the caller's lone-word 

563 # setting names" -- `Policy.lone_name_word`, a convention, not a 

564 # determination: O4 has no positions to compare at one word, so 

565 # this line is where the library picks one of two equally 

566 # consistent readings and picks it the same way every time. A 

567 # script that settles the order decides a lone word's field too 

568 # (O5's W4 Except). The rules that DO decide such a name (H1, M4 

569 # and N3's suffix clause; N3's bare shape returns before reaching 

570 # here) run after this and retag (decisions/O5, #1111). 

571 lone_field = (order[0] if resolved.by_script 

572 else state.policy.lone_name_word) 

573 roles = ([lone_field] if len(name_pieces) == 1 

574 else _name_positions(order, len(name_pieces))) 

575 for pos, piece_idx in enumerate(name_pieces): 

576 _set_roles(tokens, pieces[piece_idx], roles[pos]) 

577 for piece_idx in suffix_pieces: 

578 _set_roles(tokens, pieces[piece_idx], Role.SUFFIX) 

579 

580 # rules/O4: "a name word followed by nothing but initials reports 

581 # the order" -- the positional read takes the word as the given 

582 # name, where a byline writes its family name first (`Armitage J 

583 # O`); declared nowhere, so the default given-first order picked it 

584 # (decisions/O4, #1254). Bare capitals (`Armitage JO`) are no 

585 # initials here: they are written as a short surname is (`Zhi LI`). 

586 # Inline and nested so a plain name exits at its second piece 

587 # (the parse-cost band, decisions/parse-cost). No test of the comma 

588 # or the segment is needed: assign calls this for segment 0 under 

589 # no comma or a suffix comma alone. Nor of a script's order: one 

590 # needs the name written wholly in Han, Hangul or kana, which hold 

591 # no initial (rules/T5) for the walk below to find (#1348 review). 

592 order_report: PendingAmbiguity | None = None 

593 if (len(name_pieces) >= 2 and order[0] is Role.GIVEN 

594 and not state.policy.initials_as_family): 

595 first = pieces[name_pieces[0]] 

596 # a particle in front is PARTICLE_OR_GIVEN's fork, reported 

597 # there; an initial in front is refused below by its one letter 

598 initialed = (len(first) == 1 

599 and "particle" not in tokens[first[0]].tags) 

600 for k in name_pieces[1:]: 

601 if not initialed: 

602 break 

603 piece = pieces[k] 

604 letters = tokens[piece[0]] 

605 # chunks of the composed spelling, as classify measures a 

606 # letter (`composed_len`): a decomposed `É.S.` is one run; 

607 # the shape tag is given only to letters (period_joined_vocab's 

608 # alphabetic gate), so a chunk's length is the whole test 

609 initialed = len(piece) == 1 and ( 

610 "initial" in letters.tags 

611 or SHAPE_ACRONYM_TAG in letters.tags 

612 and all(len(chunk) == 1 

613 for chunk in unicodedata.normalize( 

614 "NFC", letters.text).rstrip(".").split("."))) 

615 text = unicodedata.normalize("NFC", tokens[first[0]].text) 

616 # the word in front is a name word a byline could write first: 

617 # no run of initials itself (rules/T7's `J.A.`), and in a script 

618 # that writes initials, as no byline in Han or Hangul does 

619 if (initialed and any(c.isalpha() for c in text) 

620 and not all(len(chunk) == 1 

621 for chunk in text.rstrip(".").split(".")) 

622 and not in_initialless_script(text)): 

623 order_report = PendingAmbiguity( 

624 AmbiguityKind.ORDER, 

625 f"{tokens[first[0]].text!r} is the only name word before " 

626 f"initials, which a byline writes after the family name " 

627 f"and a South Asian name writes as the family; read as ", 

628 tuple(first), 

629 # rules/A1: "the order (O4), whose other reading lays out 

630 # the whole name rather than moving one word" -- so the 

631 # report names none and says it in words (decisions/O4) 

632 field_tail=(" by the given-first order; read family " 

633 "first (name_order=FAMILY_FIRST), the word in " 

634 "front is the family name and the letters " 

635 "the given and middle initials")) 

636 

637 def as_a_name(i: int) -> PendingReading: 

638 # every other word read as the parse reads it -- the carve-out's 

639 # name word too -- the name word the reading declines would be 

640 # the last of one more than the name's; asked per report, so a 

641 # name that makes no report here pays nothing 

642 front = (pieces[name_pieces[-1]] if name_pieces 

643 and i not in pieces[name_pieces[-1]] else None) 

644 # rules/P5's other licence, a given-name title ahead, asked of 

645 # the leading run through the predicate group's join asks, so 

646 # the two cannot disagree about what one run asserts; asked 

647 # only where the bound branch could take it, so a report per 

648 # word of a long run never walks the name (the scaling test) 

649 licensed = (front is not None and len(name_pieces) == 1 

650 and "vocab:bound-given" in tokens[front[0]].tags 

651 and _run_addresses_by_given( 

652 (tokens[j].text for k in range(name_pieces[0]) 

653 for j in pieces[k]), 

654 state.lexicon._sets.given_name_titles)) 

655 return _name_alt(tokens[i], 

656 _slot_field(state, seg_idx, len(name_pieces), 

657 len(name_pieces) + 1, order), 

658 front, tokens, len(name_pieces) == 1, licensed) 

659 

660 for r in range(trailing_alts, len(ambiguities)): 

661 report = ambiguities[r] 

662 ambiguities[r] = copy_with( 

663 report, alternative=as_a_name(report.indices[0])) 

664 # after the trailing reports are given their name reading, which 

665 # the order report has none of 

666 if order_report is not None: 

667 ambiguities.append(order_report) 

668 # Both emitters below turn on the PEEL's count, so neither can 

669 # reach a name that kept two name pieces, and the two scans are 

670 # nested under the count rather than run beside it: they cost the 

671 # call budget on every parse otherwise (decisions/parse-cost). 

672 if peeled.names <= 1: 

673 head = pieces[name_pieces[0]] 

674 # Both conventions here turn on a lone name word, so both 

675 # report at the site that places one 

676 # (mechanisms.md#AMBIGUITY-AT-THE-DECISION-SITE), and one 

677 # predicate carries what says nothing else decided the FIELD: 

678 # a title (segment 0's own peel, or one a family comma left in 

679 # the next segment -- either is a Role.TITLE by now, and 

680 # either makes the reading H1's), a maiden name (M4's), or a 

681 # script order convention. `resolved.by_script` rather than a 

682 # comparison against name_order: a declared family-first order 

683 # agreeing with a Han name's entry is agreement, not 

684 # authorship, and for a lone CJK honorific ('さん', '씨') it 

685 # scopes W2's reading out rather than excusing it (#271/#308). 

686 titled = any(t.role is Role.TITLE for t in tokens) 

687 field_undecided = ( 

688 not resolved.by_script and not titled 

689 and not any(t.role is Role.MAIDEN for t in tokens)) 

690 # rules/H4: "an input whose every word is post-nominal 

691 # vocabulary reads its first word as a name and reports 

692 # `suffix-or-name`" (history: decisions/H4) -- only the 

693 # word made into a name reports, and no name piece survived 

694 # the peel, so the carve-out above made the first post-nominal 

695 # the name. Its field is worded by assemble from the final role 

696 # (`PendingAmbiguity.field_tail`, #626). 

697 if peeled.names == 0 and field_undecided: 

698 text = " ".join(tokens[i].text for i in head) 

699 ambiguities.append(PendingAmbiguity( 

700 AmbiguityKind.SUFFIX_OR_NAME, 

701 f"{text!r} is post-nominal vocabulary with no name word " 

702 f"beside it; read as ", tuple(head), 

703 field_tail=" rather than a post-nominal, nothing else " 

704 "being left to be the name", 

705 alternative=suffix_reading(tokens[head[0]]))) 

706 # One name piece off the peel: the convention placed a lone 

707 # name word. A suffix beside it is not a decision -- 'Smith 

708 # Jr.' IS this convention -- and the count comes off the peel 

709 # rather than off name_pieces, which is what keeps the carve-out 

710 # above out of these branches. Title vocabulary in the unit makes 

711 # the doubt H4's or H7's (is the WORD a title), anything else 

712 # O5's (which FIELD it took, or N3's where a nickname decides 

713 # it), so only the second reads `field_undecided`: a title or a 

714 # maiden name beside a lone title word is placed by H1 or M4 

715 # whatever N3 does here, and a script order never reaches this 

716 # branch (#1169). 

717 if peeled.names == 1 and not resolved.by_script: 

718 # rules/H4: "an input whose only remaining name word 

719 # after the title peel is itself title vocabulary reads 

720 # that word as the name by convention and reports 

721 # `title-or-name`" (history: decisions/H4), and 

722 # rules/H7: "one name UNIT of more than one word carrying 

723 # title vocabulary reports `title-or-name`" (history: 

724 # decisions/H7, decisions/H4 #518) -- one branch, its detail 

725 # naming a field only in H7's shape ('John of Bishop', 

726 # 'Attorney General of Minnesota'), where the unit is more 

727 # than the title word. 

728 # A LONE title word never reaches here ('Dr.', 'Prince of 

729 # Wales'): the leading-title peel took the whole name. 

730 if any("vocab:title" in tokens[i].tags for i in head): 

731 text = " ".join(tokens[i].text for i in head) 

732 lone = len(head) == 1 

733 # rules/N3: "a name that is only a nickname and one name 

734 # word reads that word as the family name, whatever 

735 # suffix stands beside them" -- a title word left as 

736 # that word too, its doubt (is it a title) still H4's 

737 # report; the scan behind `state.extracted`, as at N3's 

738 # other sites (decisions/N3, #1169) 

739 # The word's own reading, as O5's clause below asks it: 

740 # a title-particle ('St', 'Graf') or an initial keeps 

741 # the reading it has, as N3's Except has it 

742 if (lone and state.extracted 

743 and tokens[head[0]].tags.isdisjoint( 

744 _WORD_ALREADY_CLAIMED) 

745 and any(t.role is Role.NICKNAME for t in tokens)): 

746 _set_roles(tokens, head, Role.FAMILY) 

747 ambiguities.append(PendingAmbiguity( 

748 AmbiguityKind.TITLE_OR_NAME, 

749 f"{text!r} is title vocabulary and the only name word " 

750 f"the title peel left standing; read as the name by " 

751 f"convention rather than as more title" 

752 if lone else 

753 f"{text!r} is the only name unit and joins title " 

754 f"vocabulary to a name word; read as ", 

755 tuple(head), 

756 field_tail=None if lone else " by convention", 

757 alternative=TITLE_READING)) 

758 # rules/O5: "a name of one name word that nothing else has 

759 # decided reads that word as the field the caller's lone-word 

760 # setting names" -- the report for that reading (history: 

761 # decisions/O5, #449, #1111). Past 

762 # `field_undecided`, two clauses of O5's own: the word's 

763 # own reading may have claimed it, and A2's content test 

764 # -- a piece with no alphanumeric character is no name 

765 # word, so parse("(") keeps its unbalanced-delimiter 

766 # report and gains nothing here. 

767 elif (field_undecided 

768 and all(tokens[i].tags.isdisjoint(_WORD_ALREADY_CLAIMED) 

769 for i in head) 

770 and any(c.isalnum() for i in head 

771 for c in tokens[i].text)): 

772 # rules/N3: "a name that is only a nickname and one name 

773 # word reads that word as the family name, whatever 

774 # suffix stands beside them" -- the suffixed shape the 

775 # early count above declines, decided here, where the 

776 # peel has set the suffix aside and O5's clauses have 

777 # said nothing else decided the word (decisions/N3, 

778 # #815). The early count's scan, behind 

779 # `state.extracted` as at N3's bare site above (a 

780 # nickname is only ever a clause extract), so a name 

781 # with no clause pays no frame for it. 

782 if state.extracted and any(t.role is Role.NICKNAME 

783 for t in tokens): 

784 _set_roles(tokens, head, Role.FAMILY) 

785 else: 

786 text = " ".join(tokens[i].text for i in head) 

787 ambiguities.append(PendingAmbiguity( 

788 AmbiguityKind.GIVEN_OR_FAMILY, 

789 f"{text!r} is the only name word and nothing else " 

790 f"decides it; read as ", tuple(head), 

791 field_tail=" by convention, the field " 

792 "Policy.lone_name_word names", 

793 # the other primary field, named from the final 

794 # role by assemble 

795 alternative=PendingReading(ReadingKind.NAME, 

796 ReadingSource.POSITION, 

797 Deferred.OTHER_PRIMARY))) 

798 # rules/S10: "a one-letter numeral before a credential is the 

799 # family name, and the reading is reported" -- the part's last name 

800 # word with nothing but the trailing run behind it (#721, Derek: 

801 # 'John Smith V PhD' keeps family 'V' and reports, as 'john i, phd' 

802 # does). Decided here, where the trailing read has said what that 

803 # run is 

804 # `_ROMAN` first, C-level, so an ordinary name word pays no frame 

805 if (len(name_pieces) >= 2 

806 and len(last := pieces[name_pieces[-1]]) == 1 

807 and _ROMAN.match((word := tokens[last[0]].text).rstrip(".")) 

808 is not None 

809 # the trailing run, or the Ph./D. pair, which the walk 

810 # leaves out, behind it 

811 and (suffix_pieces or titled_tail 

812 or name_pieces[-1] + 1 < len(pieces) 

813 and "suffix" in ptags[name_pieces[-1] + 1]) 

814 and tuple(last) not in peeled.picks 

815 and reads_as_a_reported_numeral( 

816 word, tokens[last[0]].tags, 

817 tokens[pieces[name_pieces[-2]][0]].text, state.one_case)): 

818 ambiguities.append(PendingAmbiguity( 

819 AmbiguityKind.SUFFIX_OR_NAME, 

820 f"{word!r} is a roman numeral, but a post-nominal or title " 

821 f"follows it; read as ", tuple(last), 

822 field_tail=" rather than a generational suffix", 

823 alternative=suffix_reading(tokens[last[0]]))) 

824 for piece in peeled.picks: 

825 # every pick is in rest, so the loops above just gave it a 

826 # role; whether it was read as a suffix or a name settles what 

827 # it was declined as, and which field assemble names (#626) 

828 token = tokens[piece[0]] 

829 assert token.role is not None 

830 # rules/S10: "either reading is reported" -- the numeral fork's 

831 # declined pick (#718), which carries neither credential tag 

832 # (the acronym fork's picks carry one) and is never a suffix 

833 if token.tags.isdisjoint(_AMBIGUOUS_CREDENTIAL_TAGS): 

834 # a pick a credential run in front then took is the run's 

835 # absorbed name word (rules/S8); otherwise it is declined 

836 # for want of words to spare (one letter) or for its Title 

837 # case (two or more, rules/S10's lean) 

838 if token.role is Role.SUFFIX: 

839 ambiguities.append(_absorbed(piece, tokens, 

840 as_a_name(piece[0]))) 

841 continue 

842 why = ("is a roman numeral, but the name has no other word " 

843 "to spare for it" if len(token.text.rstrip(".")) == 1 

844 else "is a roman numeral written in Title case, which " 

845 "leans to the name") 

846 ambiguities.append(PendingAmbiguity( 

847 AmbiguityKind.SUFFIX_OR_NAME, 

848 f"{token.text!r} {why}; read as ", piece, 

849 field_tail=" rather than a generational suffix", 

850 alternative=suffix_reading(token))) 

851 continue 

852 declined = ("a name part" if token.role is Role.SUFFIX 

853 else "a post-nominal") 

854 # rules/S9: "An unlisted word joins the ambiguous class (S4) by 

855 # SHAPE" (and rules/S3); such a word is in no 

856 # wordlist and may be written with its periods, so the listed 

857 # member's wording would misdescribe it on both counts (#563) 

858 what = ("is shaped like a post-nominal but listed in no " 

859 "vocabulary, so it may be an ordinary name" 

860 if SHAPE_ACRONYM_TAG in token.tags 

861 else "written without periods is both a post-nominal " 

862 "and an ordinary name") 

863 ambiguities.append(PendingAmbiguity( 

864 AmbiguityKind.SUFFIX_OR_NAME, 

865 f"{token.text!r} {what}; read as ", piece, 

866 field_tail=f" rather than {declined}", 

867 alternative=(as_a_name(piece[0]) 

868 if token.role is Role.SUFFIX 

869 else suffix_reading(token)))) 

870 # leading ambiguous particle read as a name (#121 surfaced) 

871 if name_pieces: 

872 head = pieces[name_pieces[0]] 

873 # rules/P11: "an ambiguous particle written in lower case, in a 

874 # name of mixed case, opens the name as a never-given one does" 

875 # -- and "nothing is reported" there, the writing having settled 

876 # the fork (decisions/P11, #670) 

877 if (len(head) == 1 and len(name_pieces) > 1 

878 and "vocab:particle-ambiguous" in tokens[head[0]].tags 

879 and not (order is not None and order[0] is Role.FAMILY 

880 and is_lowercase_ambiguous_particle( 

881 head, tokens, state.one_case))): 

882 # the field is assemble's to word from the final role 

883 # (`PendingAmbiguity.field_tail`, #626), never assumed given: 

884 # `order` above is _effective_order's answer, not 

885 # necessarily name_order's, and a later rule may still move 

886 # the word ('Van Ivan Petrovich' under the East Slavic rule 

887 # rotates 'Van' into the family) 

888 ambiguities.append(PendingAmbiguity( 

889 AmbiguityKind.PARTICLE_OR_GIVEN, 

890 f"leading {tokens[head[0]].text!r} may be a family-name " 

891 f"particle; read as ", tuple(head), field_tail="", 

892 alternative=particle_reading(tokens[head[0]].tags))) 

893 return order 

894 

895 

896def _names_before(fam_pieces: Sequence[Sequence[int]], 

897 tokens: Sequence[WorkToken], lead: int, k: int) -> int: 

898 """How many name words stand in front of the numeral at 

899 `fam_pieces[k]`, the title run's `lead` pieces left out: the pieces 

900 that are not wholly linking words (`_is_linking_word`).""" 

901 # rules/S10: "a particle or a connective is no name word there, 

902 # wherever it stands in the part" -- the count of the family part's 

903 # numeral site; a chain or join holding a name word is one piece and 

904 # counts (decisions/P9 (#1080), decisions/S10 (#1093)) 

905 return sum(1 for j in range(lead, k) 

906 if not all(_is_linking_word(tokens[t].tags, j == k - 1) 

907 for t in fam_pieces[j])) 

908 

909 

910def _is_linking_word(tags: Set[str], beside: bool) -> bool: 

911 """Whether a word of the family part is a linking word, which the 

912 numeral's count leaves out: a particle, or a connective, save one 

913 that is also suffix vocabulary standing `beside` the numeral.""" 

914 # rules/S10: "A connective that is also suffix vocabulary, as the 

915 # generational 'i' is, stays in the count where P13 has not linked 

916 # the letter" -- the exception's half of this predicate 

917 # (decisions/P13 (#1097)) 

918 return "particle" in tags or ("conjunction" in tags and not ( 

919 beside and "vocab:suffix" in tags)) 

920 

921 

922def _family_part_numeral(word: WorkToken, preceding: str, names: int, 

923 one_case: bool | None) -> Role | None: 

924 """The role of a roman numeral ending the family part before a 

925 family comma, behind `names` name words of that part (`_names_before`, 

926 which leaves its linking words out), as the comma-less name reads one 

927 ending it (rules/S10), or None where the numeral's 

928 predicate reads no numeral there (an initial in front, a letter with 

929 its period): the suffix where it reads as the generation, a 

930 one-letter numeral needing two name words in front of it, save one 

931 its written case settles (T4), and the family name where that count 

932 fails or its Title case leans to the name.""" 

933 if reads_as_generation(word.text, word.tags, preceding, one_case): 

934 if (names < 2 and len(word.text) == 1 

935 and not numeral_settled_by_case(word.text, one_case)): 

936 return Role.FAMILY 

937 return Role.SUFFIX 

938 if numeral_leans_to_the_name(word.text, word.tags, preceding, one_case): 

939 return Role.FAMILY 

940 return None 

941 

942 

943def _reads_as_a_trailing_suffix(piece: Sequence[int], 

944 prev_piece: Sequence[int], 

945 prev_ptags: Set[str], 

946 tokens: Sequence[WorkToken], 

947 lexicon: Lexicon, 

948 one_case: bool | None) -> bool: 

949 """The lenient tail test for a trailing one-token piece after a 

950 family comma, and #432's carve-out from it. 

951 

952 A word that could be a middle initial, written with the period that 

953 marks an abbreviation, is name material -- so 'Smith, John V.' is 

954 middle 'V.' where 'Smith, John V' stays suffix 'V' (v1 parity, 

955 #144). Only behind a NAME word: behind a suffix the credential run 

956 owns it, and 'Smith, John PhD I.' keeps suffix 'PhD, I.'. 

957 

958 The period is the whole carve-out. is_initial_shaped would be 

959 redundant beside it: the caller only consults this where 

960 is_suffix_piece said no, and a lenient single token it refuses is 

961 one carrying the `initial` tag, so the shape is already implied. 

962 

963 reads_as_generation is asked with no word in front (`preceding` 

964 None): its initial-in-front refusal is a no-comma rule and the 

965 opposite of this path's v1 parity -- 'Chang, Andy C I' is first 

966 Andy, middle C, suffix I, and asking it with the word in front 

967 made the numeral a middle. The #401/#421 entry 

968 under decisions/P5 records the same fork declining to transfer 

969 to this walk under LENIENT. 

970 """ 

971 # rules/C9: "behind a name word it is the generation only when 

972 # written bare, a period marking it the abbreviation of a name and 

973 # so a middle initial" 

974 tok = tokens[piece[0]] 

975 text = tok.text 

976 if text.endswith(".") and not is_suffix_piece( 

977 prev_piece, prev_ptags, tokens): 

978 return False 

979 # the numeral's predicate, which in this slot reads a bare 

980 # one-letter numeral by the vocabulary ('Smith, John X' keeps 

981 # middle 'X'); `_ROMAN` first, C-level 

982 if len(text) == 1 and _ROMAN.match(text) is not None: 

983 return reads_as_generation(text, tok.tags, None, one_case) 

984 return is_suffix_lenient(text, lexicon) 

985 

986 

987def _inside_a_credential_run(seg: Sequence[Sequence[int]], 

988 tokens: list[WorkToken], given_at: int, 

989 k: int, end: int, 

990 ambiguities: list[PendingAmbiguity]) -> bool: 

991 """rules/P9 (P6's third exception) for the run `seg[k:end]`: a 

992 run assign read as post-nominals, with a credential read in 

993 front of it and another behind, stands INSIDE the credential run 

994 assign read whole rather than ending the name, so it keeps that 

995 reading -- 'DOE, JANE PHD VD MA' reads suffix 'PHD VD MA' where the 

996 attachment had pulled VD out of the middle of it (#573). Behind as 

997 well as in front: with nothing behind it the word ends the name, 

998 and 'Doe, Jane PhD vd' keeps family 'vd Doe'. A title between it 

999 and the post-nominal in front is transparent, as it is to every 

1000 trailing reading (H5): 'DOE, JANE PHD PROF. VD MA' reads as 'DOE, 

1001 JANE PHD VD MA' does. The run is never empty: the caller asks 

1002 only where `k < end`, and over an empty range the test below would 

1003 pass vacuously and report a fork on the word behind (#613's 

1004 review, ', Jane PhD do MA'). (Behind it no title is skipped: a title 

1005 there leaves the word a name in assign, so nothing reaches here.) 

1006 

1007 The test is the ROLES assign gave, not S7's company query: the 

1008 question is whether assign read the run whole, and a member in 

1009 front that the writing or a count made the credential ('DOE, JANE 

1010 MA VD PHD') says so as plainly as a degree does. So does a 

1011 generation, in front or behind ('Berg, Jan PhD vd Jr.'): the 

1012 tussenvoegsel P6 is about stands right behind the given name, and 

1013 a post-nominal between them says this word is not one. The run's own 

1014 role is what keeps a plain particle out ('Doe, Jane PhD de PhD', 

1015 whose 'de' is a name word, not a post-nominal), and the given 

1016 name in front is what keeps 'Doe, Jane vd PhD' attaching. 

1017 Reported as S4's credential fork, at the site that declines the 

1018 attachment.""" 

1019 def suffix_read(q: int) -> bool: 

1020 return all(tokens[i].role is Role.SUFFIX for i in seg[q]) 

1021 

1022 def is_title(q: int) -> bool: 

1023 return all(tokens[i].role is Role.TITLE for i in seg[q]) 

1024 

1025 if not all(suffix_read(q) for q in range(k, end)): 

1026 return False 

1027 front = k - 1 

1028 while front > given_at and is_title(front): 

1029 front -= 1 

1030 if (front <= given_at or end == len(seg) 

1031 or not suffix_read(front) or not suffix_read(end)): 

1032 return False 

1033 run = seg[k] 

1034 text = " ".join(tokens[i].text for i in run) 

1035 ambiguities.append(PendingAmbiguity( 

1036 AmbiguityKind.SUFFIX_OR_NAME, 

1037 f"{text!r} written without periods is both a post-nominal and a " 

1038 f"family-name particle; between credentials after a family " 

1039 f"comma it reads as a post-nominal", 

1040 tuple(run), alternative=particle_reading(tokens[run[0]].tags))) 

1041 return True 

1042 

1043 

1044def _attach_particle_tail(pieces: Sequence[Sequence[int]], 

1045 tokens: list[WorkToken], given_at: int, 

1046 k: int, end: int, 

1047 ambiguities: list[PendingAmbiguity]) -> None: 

1048 """rules/P6's attachment after a family comma: the particle run 

1049 `pieces[k:end]` ending the given part joins the family the comma 

1050 named and is written before it -- the Dutch alphabetized listing, 

1051 "Beethoven, Ludwig van" being how "Ludwig van Beethoven" is filed. 

1052 The caller has found the run (`particle_tail`) and checked that a 

1053 given word stands ahead of it and that P1's fold will not take the 

1054 part; this declines only where the run stands inside a credential 

1055 run the walk read whole (`_inside_a_credential_run`, the third 

1056 exception). 

1057 

1058 Keyed on the tokens' VOCABULARY, not their roles, which is what 

1059 gives the attachment its stated precedence over S2: `vd`, `mc` and 

1060 `do` are the three words in both vocabularies; the walk reads a 

1061 trailing `vd` or `mc` as a post-nominal, so those two need the 

1062 override, and `do` is in the AMBIGUOUS acronym half, which already 

1063 leaves it a name word, so it attaches by the plain rule. After a 

1064 family comma the tussenvoegsel is the commoner reading. 

1065 

1066 The words-to-spare guard is a piece test, not a count: every 

1067 trailing piece that is wholly particles attaches, and the run must 

1068 leave a GIVEN word ahead of it, so "Nguyen, Van" keeps its only 

1069 given word. Only the DEGENERATE Vietnamese listing is protected by 

1070 that -- "Nguyen, Thi Van" has a given word to spare, so `Van` 

1071 attaches and the given name is lost. rules/P6 records why that 

1072 is accepted. 

1073 

1074 mechanisms.md#FOLDED_TAG does the rest: tokens never move, so the 

1075 family view reads the tag and renders these before the base. 

1076 

1077 Decided in assign since #613, in the pass that read the given 

1078 part; until then post_rules took it a stage later, and assign held 

1079 the tail back from #602's run by predicting it.""" 

1080 if _inside_a_credential_run(pieces, tokens, given_at, k, end, 

1081 ambiguities): 

1082 return 

1083 run = [i for piece in pieces[k:end] for i in piece] 

1084 # mechanisms.md#AMBIGUITY-AT-THE-DECISION-SITE: "Emit at 

1085 # the site that takes the branch, not where an ambiguous 

1086 # tag sits" -- this attachment is where the fork is 

1087 # decided, so the report is raised here, at the attachment, 

1088 # rather than by the given-part walk's own emitters, which 

1089 # read the run before the attachment decides it (#405). 

1090 # 

1091 # Two arms, keyed on what the attachment OVERRODE rather 

1092 # than on what the words are, so each names a branch the 

1093 # parse actually weighed. `declined_suffix` reads the role 

1094 # the walk gave the run, which is why both lists are built 

1095 # BEFORE the re-roling loop below overwrites it. 

1096 # 

1097 # ORDERED, not asserted disjoint. The two cannot both hold 

1098 # under the shipped vocabulary -- `vd` and `mc` are the 

1099 # only words in both the particle and the unambiguous 

1100 # suffix vocabularies, and neither is ambiguous particle 

1101 # vocabulary -- but a caller's Lexicon may put one word in 

1102 # both, and rules/A1 says parsing never fails on any 

1103 # input, so this decides instead of raising. (Measured: an 

1104 # assert here DID fire on `Berg, Jan zz` under a Lexicon 

1105 # adding `zz` to particles_ambiguous and suffix_acronyms.) 

1106 # The suffix arm wins because it names the reading the 

1107 # parse actually took: assign had read the word as a 

1108 # post-nominal, so the name-word reading was never on the 

1109 # table for the attachment to decline. 

1110 # 

1111 # Both details name the ATTACHMENT and stop there. What 

1112 # P6 decides is that the run joins the family the comma 

1113 # named instead of standing on its own; how the joined 

1114 # words then READ is R2's call, taken by the UNJOINED_TAG 

1115 # loop at the end of post_rules. The two can disagree: 

1116 # `de la, Jan van` attaches `van` and leaves an 

1117 # all-particle family, which R2 marks, so every other view 

1118 # -- `family_base`, the initials, case repair -- reads 

1119 # those words as ordinary name words ('Jan Van De La' 

1120 # forced). A detail promising "read as the family's 

1121 # particle" would contradict all three. 

1122 ambiguous = [i for i in run 

1123 if "vocab:particle-ambiguous" in tokens[i].tags] 

1124 declined_suffix = [i for i in run 

1125 if tokens[i].role is Role.SUFFIX] 

1126 text = " ".join(tokens[i].text for i in run) 

1127 if declined_suffix: 

1128 ambiguities.append(PendingAmbiguity( 

1129 AmbiguityKind.SUFFIX_OR_NAME, 

1130 f"{text!r} written without periods is both a " 

1131 f"post-nominal and a family-name particle; after a " 

1132 f"family comma it joins the family the comma named " 

1133 f"rather than standing as a post-nominal", 

1134 tuple(run), alternative=suffix_reading(tokens[run[0]]))) 

1135 elif ambiguous: 

1136 word = tokens[ambiguous[0]].text 

1137 walked = tokens[run[0]].role 

1138 assert walked is not None # the given part's walk roled the run 

1139 ambiguities.append(PendingAmbiguity( 

1140 AmbiguityKind.PARTICLE_OR_GIVEN, 

1141 f"{word!r} is both a family-name particle and an " 

1142 f"ordinary given name; after a family comma " 

1143 f"{text!r} joins the family the comma named " 

1144 f"rather than standing as a name word of its own", 

1145 tuple(run), 

1146 # where the given part's walk placed it, before this moves 

1147 # it: a name word, or a title the walk read off its period 

1148 # ('Doe, Jane nee Smith St.'); a name word is taken in by 

1149 # the piece in front where that piece would take it 

1150 alternative=(TITLE_READING if tokens[run[0]].role is Role.TITLE 

1151 else _name_alt( 

1152 tokens[ambiguous[0]], 

1153 walked, 

1154 pieces[k - 1] if k - 1 >= given_at 

1155 and tokens[pieces[k - 1][0]].role in NAME_ROLES 

1156 else None, 

1157 tokens, k - 1 == given_at, True)))) 

1158 for i in run: 

1159 tokens[i] = copy_with( 

1160 tokens[i], role=Role.FAMILY, 

1161 tags=tokens[i].tags | {FOLDED_TAG, ATTACHED_TAG}) 

1162 

1163 

1164def _split_name_alt(state: ParseState, tokens: Sequence[WorkToken], 

1165 seg_idx: int, piece: Sequence[int], 

1166 order: tuple[Role, Role, Role] | None) -> PendingReading: 

1167 """The name reading of a split credential assign placed, every 

1168 other word read as the parse read it: the family before a family 

1169 comma, the given part's given or middle name after one, and 

1170 otherwise the last of the part's name words -- or wherever the name 

1171 piece in front of it would take it in (`_name_alt`).""" 

1172 names = [q for q in _name_pieces(state, tokens, seg_idx) 

1173 if q[0] < piece[0]] 

1174 role = _slot_field(state, seg_idx, len(names), len(names) + 1, order) 

1175 return _name_alt(tokens[piece[0]], role, names[-1] if names else None, 

1176 tokens, len(names) == 1, 

1177 state.structure is Structure.FAMILY_COMMA) 

1178 

1179 

1180def _resolve_deferred(state: ParseState, tokens: Sequence[WorkToken], 

1181 ambiguities: list[PendingAmbiguity], 

1182 order: tuple[Role, Role, Role] | None) -> None: 

1183 """Name the field of each alternative classify or group deferred, 

1184 from the positions just read: a connective's join takes in the name 

1185 word on each side, so where those stand in two pieces the joined 

1186 unit takes the place of the first, and where both are inside the 

1187 letter's own piece, or the join would leave the name one word, the 

1188 letter keeps its own field (named at assemble); a chained particle 

1189 stands as a name word of its own in front of its piece, one more 

1190 unit. Every other word keeps its reading, so the field is 

1191 the one the positional read gives that place: the family before a 

1192 family comma, the given part's given or middle name after one, and 

1193 the declared order's otherwise. A referent outside every name 

1194 position is left deferred, and surfaces as no alternative.""" 

1195 for r, report in enumerate(ambiguities): 

1196 alt = report.alternative 

1197 if (alt is None or alt.role not in (Deferred.JOINED, 

1198 Deferred.UNCHAINED) 

1199 or not report.indices): 

1200 continue 

1201 i = report.indices[0] 

1202 for seg_idx in range(len(state.pieces)): 

1203 names = _name_pieces(state, tokens, seg_idx) 

1204 at = next((k for k, q in enumerate(names) if i in q), None) 

1205 if at is not None: 

1206 break 

1207 else: 

1208 continue 

1209 if alt.role is Deferred.UNCHAINED: 

1210 pos, count = at, len(names) + 1 

1211 else: 

1212 # the join takes in the name word on each side: a word of 

1213 # the connective's own piece, or the piece beside it. One 

1214 # inside its piece joins nothing the piece did not hold, so 

1215 # it lands where its piece lands; one opening or ending the 

1216 # name has no word to join on that side, and no field 

1217 piece = names[at] 

1218 k = piece.index(i) 

1219 first = at - 1 if k == 0 else at 

1220 last = at + 1 if k == len(piece) - 1 else at 

1221 if first < 0 or last >= len(names): 

1222 continue 

1223 pos, count = first, len(names) - (last - first) 

1224 # rules/P3: "a single-letter connective in a three-word name" 

1225 # stays a name word, so a join that would leave the name one 

1226 # word is no join, and the letter keeps its own field; a 

1227 # family comma's surname is a word of the name the given 

1228 # part's count does not see 

1229 if first == last or ( 

1230 count == 1 

1231 and state.structure is not Structure.FAMILY_COMMA): 

1232 ambiguities[r] = copy_with(report, alternative=copy_with( 

1233 alt, role=Beside(i))) 

1234 continue 

1235 ambiguities[r] = copy_with(report, alternative=copy_with( 

1236 alt, role=_slot_field(state, seg_idx, pos, count, order))) 

1237 

1238 

1239def assign(state: ParseState) -> ParseState: 

1240 tokens = list(state.tokens) 

1241 ambiguities = list(state.ambiguities) 

1242 if not state.segments: 

1243 return state 

1244 order: tuple[Role, Role, Role] | None = None 

1245 if state.structure is Structure.NO_COMMA: 

1246 order = _assign_main(0, state, tokens, ambiguities) 

1247 tail = len(state.segments) 

1248 elif state.structure is Structure.SUFFIX_COMMA: 

1249 order = _assign_main(0, state, tokens, ambiguities) 

1250 tail = 1 

1251 else: # FAMILY_COMMA 

1252 # PARTICLE_OR_GIVEN is deliberately not emitted on the 

1253 # wholly-family read: after a comma that fixed the family, a 

1254 # leading given-position particle is not meaningfully 

1255 # ambiguous, and script_orders is not consulted for the parallel 

1256 # reason. Scoped, not silent: the comma fixed WHICH PIECE is the 

1257 # family and said nothing about a particle trailing the given 

1258 # name, so P6's attachment after the given-part walk below 

1259 # decides that fork and reports it there, at the site that 

1260 # takes the branch (#405). Behind a whole name, a comma followed 

1261 # by no name word is not this path since #613: `_comma.decide` 

1262 # makes it the suffix comma, read positionally by the branch 

1263 # above, which emits and consults both as the comma-less name 

1264 # does -- and group's chain emitter with them, the comma being 

1265 # decided before the chain runs. Behind one name word it is 

1266 # this path, with segment 1 already bound ('Smith, Jr.'). 

1267 # v1: "lastname part may have suffixes in it" -- the first 

1268 # piece is always the family even if suffix-shaped; any later 

1269 # strict-suffix piece goes to SUFFIX per piece ('Smith Jr., 

1270 # John' -> family=Smith, suffix=Jr.) 

1271 fam_pieces = state.pieces[0] 

1272 fam_tags = state.piece_tags[0] 

1273 # The part after the comma reaches here as the given part, or 

1274 # bound and with no pieces left: group's `_comma.decide` binds a 

1275 # postnominal one before any join (#613, rules/C7), so this 

1276 # path is the listing form's, and the positional read #296 gave 

1277 # a comma followed by no name word behind two name words is 

1278 # decide's suffix comma now, read by the NO_COMMA branch above. 

1279 # rules/C11's report, scoped to the ambiguous credential 

1280 # class: this is the given-name half of the comma's OWN 

1281 # decision (listing or credential run), where the writing left 

1282 # the fork open. `_comma.decide` reports the credential half, 

1283 # where it binds the part, and P6's attachment fork reports 

1284 # from `_attach_particle_tail` above ("Berg, Jan vd") -- a 

1285 # different fork. Each half is reported where its branch is 

1286 # taken, so this DECISION is never reported twice; a second ambiguous 

1287 # token elsewhere in the name is a second fork and reports on 

1288 # its own (#289, mechanisms.md#AMBIGUITY-AT-THE-DECISION-SITE). 

1289 # 

1290 # The report tracks the FORK BEING CONSULTED, not the lean -- 

1291 # exactly as the trailing slot has always done (`Jack MA` 

1292 # reported before #289 too, even where the pick was declined 

1293 # for want of words to spare). So membership alone gates it: 

1294 # a caseless script or an all-lower spelling still called this 

1295 # fork and read it positionally. 

1296 # 

1297 # The gate reads EITHER tag (`_AMBIGUOUS_CREDENTIAL_TAGS`, the 

1298 # same pair `_group`'s chain emitter asks), and the shape one 

1299 # is what reaches a by-shape member -- under EITHER 2.4 

1300 # switch, the dotted and the caps alike, since classify writes 

1301 # it from both branches. It reaches one with the dotted switch 

1302 # OFF as well: classify writes the shape tag whether or not 

1303 # the switch admits the token to the class, which is what lets 

1304 # a declined fork be REPORTED without being taken ('Smith, 

1305 # A.B.' under `unlisted_dotted_suffixes=False` reports and 

1306 # keeps its given). An earlier wording said the class reaches 

1307 # a by-shape member "once `Policy.unlisted_dotted_suffixes` 

1308 # admits it", which is true of the CLASS and false of this 

1309 # report. 

1310 # 

1311 # Read off the FIRST post-comma piece only -- on this path it 

1312 # is the given name, `_comma.decide` having read the part and 

1313 # found a name word in it, and it is the one piece the lean 

1314 # can reach at one word before the comma. So `"Smith, MA PhD"` reports ONCE, for 'MA' alone: 

1315 # 'PhD' is settled vocabulary and carries neither tag, and 

1316 # even a second CLASS member there would not be read here 

1317 # (test_assign.py asserts the count). 

1318 # 

1319 # The #531 emitter at the far end of this branch counts 

1320 # differently, and the difference is the slot rather than a 

1321 # second policy: it reports once per member of the trailing 

1322 # run it reads, so 'Doe, John MA JD' reports TWICE, matching 

1323 # the comma-less 'John Smith MA JD'. A member the writing 

1324 # keeps as a name stops that run, which is why 

1325 # 'Doe, John MA Ma' reports once and for 'Ma' alone -- 'MA' 

1326 # then has a name word behind it and is never asked 

1327 # (rules/S5). 

1328 if state.pieces[1] and len(state.pieces[1][0]) == 1: 

1329 i = state.pieces[1][0][0] 

1330 if not tokens[i].tags.isdisjoint(_AMBIGUOUS_CREDENTIAL_TAGS): 

1331 # the field is assemble's to word: with nothing before 

1332 # the comma and a title after the word, H1 moves it to 

1333 # the family (', Ma Dr.', #626's review) 

1334 ambiguities.append(PendingAmbiguity( 

1335 AmbiguityKind.SUFFIX_OR_NAME, 

1336 f"{tokens[i].text!r} after the comma is also an " 

1337 f"ordinary name word; read as ", (i,), field_tail="", 

1338 # the comma's other branch: the part a credential run 

1339 alternative=suffix_reading(tokens[i]))) 

1340 # Segment 1 is read before segment 0's family pass. It 

1341 # consumes piece tags and text only -- nothing segment 0's 

1342 # read writes. (A title standing after the comma behind a whole 

1343 # name, 'John V, Dr.', is bound at group's head since #613 and 

1344 # never reaches this path.) 

1345 if len(state.segments) > 1: 

1346 pieces = state.pieces[1] 

1347 ptags = state.piece_tags[1] 

1348 # Both are the walk's, filled once the title run is found. 

1349 titled_idx: frozenset[int] = frozenset() 

1350 walkable: frozenset[int] = frozenset() 

1351 #: Where the trailing suffix run starts, for the #531 

1352 #: report below: `trailing_floor`'s answer, read once on 

1353 #: the first member the loop meets and -1 until then (no 

1354 #: piece index can be negative, and the loop's own floor is 

1355 #: n + 1). Once is enough for the same reason one number is 

1356 #: enough inside that walk: the question is MONOTONE, the 

1357 #: loop below ascends, and a later member can only ask for 

1358 #: LESS of the walk than the first one did. 

1359 run_floor = -1 

1360 

1361 def previous_kept(m: int, titled: frozenset[int]) -> int: 

1362 """The piece before `m` that the H5 chain did NOT 

1363 take. Both readings this segment needs are that one: 

1364 the piece the lenient tail test measures against, and 

1365 -- asked of one past the end -- where the segment's 

1366 name ENDS, since a title the chain took is not where a 

1367 name ends (#144). One walk for both, so 'as if the 

1368 titled pieces were absent' cannot come to mean two 

1369 things. 

1370 

1371 DEFENSIVE, and measured inert: over 191,146 generated 

1372 inputs the skip fired on 153 of the 140,227 walks the 

1373 lenient test's `prev` asked for, and deleting it 

1374 changed no parse among them (2026-09-09, on the shape 

1375 this replaced, where the name's END walked past the 

1376 same pieces in a copy of this loop). Kept because "as 

1377 if the titled pieces were absent" is the rule the 

1378 predicate below implements, and a caller's vocabulary 

1379 reaches shapes the sweep's word list does not -- an 

1380 inert branch is cheaper than a rule with a hole in it. 

1381 """ 

1382 m -= 1 

1383 while m in titled: 

1384 m -= 1 

1385 return m 

1386 

1387 #: State of the #531 walk below, per `titled` value: how 

1388 #: far down the trailing suffix run has been walked, and 

1389 #: whether that walk has SETTLED (it stopped on a piece 

1390 #: that refuses, so no lower piece can end the given part 

1391 #: either and the refusal is never re-asked). 

1392 #: 

1393 #: The key carries `titled` for the same reason the 

1394 #: predicate takes it as a parameter: the two passes ask 

1395 #: about the same pieces with different ones spliced out. 

1396 #: It CANNOT go stale. Everything the recorded verdict 

1397 #: rests on is tags and text -- `is_suffix_piece` reads 

1398 #: ptags and token tags, `_reads_as_a_trailing_suffix` 

1399 #: reads text plus `is_suffix_piece`, `listed_lean` reads 

1400 #: tags, text and `state.one_case` -- and none of them 

1401 #: reads `.role`, verified by reading all three 

1402 #: (2026-09-19). The one thing this segment's code rewrites 

1403 #: between the two passes is the role, through `_set_roles`, 

1404 #: which is a `copy_with(role=...)` and leaves 

1405 #: text and tags identical. 

1406 floors: dict[frozenset[int], tuple[int, bool]] = {} 

1407 #: #544's anchors, per `titled` value like `floors` and for 

1408 #: the same reason: `given_slot_anchors` over the pieces the 

1409 #: chain kept, computed once, the first time a member's own 

1410 #: writing declines, so a run of members is read in one 

1411 #: forward pass rather than one look-behind per member. 

1412 anchor_memo: dict[frozenset[int], list[bool]] = {} 

1413 

1414 def anchored(m: int, titled: frozenset[int]) -> bool: 

1415 memo = anchor_memo.get(titled) 

1416 if memo is None: 

1417 # nothing in front the pass could read as an 

1418 # anchor: the ordinary 'Smith, John Ma' stops here. 

1419 # Asked only before the pass exists -- once it 

1420 # does it answers in one lookup, where the reach 

1421 # test walks back through the run 

1422 if not anchor_in_reach(range(m - 1, -1, -1), pieces, 

1423 ptags, tokens, titled): 

1424 return False 

1425 # from past the leading title run, which `n` already 

1426 # counts: this closure is reached only from the walk 

1427 # below `_peel_leading_titles` sets it on 

1428 memo = given_slot_anchors(pieces, ptags, tokens, n, 

1429 skip=titled) 

1430 anchor_memo[titled] = memo 

1431 return memo[m] 

1432 

1433 def trailing_floor(m: int, titled: frozenset[int]) -> int: 

1434 """Where the trailing suffix run starts, walked as far 

1435 down as `m` needs it: `m >= trailing_floor(m, titled)` 

1436 is exactly "every kept piece behind `m` reads as a 

1437 suffix", which is what ENDING the given part means 

1438 (rules/S5, #531). 

1439 

1440 ONE walk per `titled` value, shared by the predicate 

1441 below and by that rule's report at the foot of this 

1442 segment, and the reason both are LINEAR in the run's 

1443 length. The question is MONOTONE -- a piece ends the 

1444 given part whenever the piece behind it does -- so a 

1445 single descent answers for every member, each piece 

1446 read at most once. Asked member by member instead, the 

1447 reading is recursive (a member ends the given part iff 

1448 everything kept behind it reads as a suffix, and a 

1449 piece behind it is a member asking the same of its own 

1450 tail): a walk per member, which cost O(run**2) with the 

1451 per-member memo this replaced and 2**run without one. 

1452 Measured on `'Doe, John ' + 'MA '*k`, k doubling from 

1453 200: 2.1/4.1/8.3/17.1ms here, against 8.6/31.4/120/463 

1454 with the memo and 1.5/3.2/7.2/17.3 at cc78c960, where 

1455 no such walk existed at all (2026-09-19). 

1456 

1457 `low` descends only to `m`, so what comes back is a 

1458 floor FOR `m` rather than the run's own first piece 

1459 whenever the run reaches past it; that is all either 

1460 caller asks, and stopping there is what keeps the 

1461 member's own frame count where it was. `final` carries 

1462 the other half: a piece that refuses settles the floor 

1463 for everything in front of it. 

1464 

1465 Re-entrant by construction, and it has to be: the 

1466 descent asks the predicate below about a piece that is 

1467 itself often a member, which asks this back. `floors` 

1468 names the piece under test BEFORE that call, so the 

1469 re-entrant reading is "this piece ends the given part", 

1470 which is what the descent has just established of it. 

1471 """ 

1472 entry = floors.get(titled) 

1473 if entry is None: 

1474 # previous_kept() of one past the end, spelled out 

1475 # here rather than called: the frame budget again, 

1476 # this walk being asked of every family-comma name 

1477 # with a member in the given part, and the skip is 

1478 # two lines. Keep the two in step. 

1479 low = len(pieces) - 1 

1480 while low in titled: 

1481 low -= 1 

1482 entry = (low, False) 

1483 floors[titled] = entry 

1484 low, final = entry 

1485 while not final and low > m: 

1486 if reads_as_a_suffix(low, titled): 

1487 low -= 1 

1488 while low in titled: 

1489 low -= 1 

1490 else: 

1491 final = True 

1492 floors[titled] = (low, final) 

1493 return low 

1494 

1495 #: where the particle run opening the given part ends, 

1496 #: per `titled` value -- `attaches_the_lead`'s one walk 

1497 lead_run_ends: dict[frozenset[int], int] = {} 

1498 

1499 def attaches_the_lead(m: int, titled: frozenset[int]) -> bool: 

1500 """Is piece `m` the name word a never-given particle 

1501 opening the given part attaches to? post_rules' P1 

1502 fold takes such a particle forward into the family and 

1503 needs a name word for it to attach to, so the given 

1504 name the #531 slot below counts on is not there, and 

1505 the first word past the particle run is the surname it 

1506 heads rather than a word to spare ('SMITH, VD MA' 

1507 reads as 'Smith, vd Ma' does, #573). Only where the 

1508 member's writing says nothing: capitals in a mixed-case 

1509 name still make it the credential ('Smith, de MA' 

1510 keeps suffix 'MA'), as they do with no words to spare 

1511 anywhere (rules/S5). Asked only of a class member 

1512 the slot would otherwise take.""" 

1513 if not is_lone_never_given_particle(pieces[n], tokens): 

1514 return False 

1515 if listed_lean(tokens[pieces[m][0]], 

1516 state.one_case) == "credential": 

1517 return False 

1518 # The member is the particle run's word when it is the 

1519 # first kept piece past that run. Where the run ends is 

1520 # one answer per `titled` value, walked once and kept, 

1521 # so a run of members costs one walk rather than one 

1522 # each (the second review measured the per-member scan 

1523 # quadratic on 'SMITH, VD ' + 'DE '*k + 'MA '*k). Every 

1524 # token, not the piece's tags: a chain of particles 

1525 # ('DE LA') is one piece whose tags say nothing of it 

1526 # ('SMITH, VD DE LA MA'). 

1527 run_end = lead_run_ends.get(titled) 

1528 if run_end is None: 

1529 run_end = n + 1 

1530 while run_end < len(pieces) and ( 

1531 run_end in titled 

1532 or all("particle" in tokens[i].tags 

1533 for i in pieces[run_end])): 

1534 run_end += 1 

1535 lead_run_ends[titled] = run_end 

1536 return m <= run_end 

1537 

1538 def reads_as_a_suffix(m: int, titled: frozenset[int]) -> bool: 

1539 """Does this segment's walk read piece `m` as a suffix? 

1540 

1541 Asked twice, and by one predicate rather than by two 

1542 conditions written to match 

1543 (mechanisms.md#ONE-PREDICATE-PER-QUESTION): once to 

1544 find the pieces the H5 title chain must not reach 

1545 past, and once by the walk order below, which is the 

1546 site that places them. 

1547 

1548 `titled` is a PARAMETER because the two passes hand it 

1549 different values -- an empty set on the first, the 

1550 chain's own pieces on the second, which is what makes 

1551 that second reading the one 'as if the titled pieces 

1552 were absent'. 

1553 A closure over the caller's local said the same thing, 

1554 but only by WHEN it was rebound. 

1555 """ 

1556 if is_suffix_piece(pieces[m], ptags[m], tokens): 

1557 return True 

1558 # rules/S10: "A numeral of two or more letters reads as 

1559 # the generation" in the given part, which asks no 

1560 # initial in front -- 'Smith, John VI' as 'Smith, John 

1561 # III' (#752); `_ROMAN` first, C-level 

1562 if (len(pieces[m]) == 1 

1563 and len(stem := (tok := tokens[pieces[m][0]]).text 

1564 .rstrip(".")) >= 2 

1565 and _ROMAN.match(stem) is not None): 

1566 return reads_as_generation(tok.text, tok.tags, None, 

1567 state.one_case) 

1568 # rules/S5: "a word that ENDS that part reads as the credential 

1569 # unless its WRITING says otherwise" -- the given part's 

1570 # trailing slot (#531). 

1571 # INLINE, and that is the frame budget talking rather 

1572 # than taste: the candidates pass asks this closure once 

1573 # per piece of every family-comma segment, so a helper 

1574 # call would cost a frame on every non-member piece and 

1575 # a generator expression would cost its own on 3.11. 

1576 # Membership is therefore a bare `in` on tags already 

1577 # in hand, after a `len` -- 'Doe, John Q.' and 

1578 # 'Smith, John V' measure +0 with this shape and +1 

1579 # with a helper (2026-09-18). 

1580 # 

1581 # That "once per piece" is the NON-MEMBER cost, and 

1582 # only it. A member is asked a second time by 

1583 # trailing_floor()'s descent above, and twice is the 

1584 # whole of it: the descent takes each piece once and 

1585 # the candidates pass asks each piece once, which is what 

1586 # makes the run linear. What the slot costs, measured 

1587 # against cc78c960: 'Smith, John', 'Doe, John Q.', 

1588 # 'Smith, John V', 'Smith, MA' and 'Berg, Jan vd' are 

1589 # all +0 frames, while 'Doe, John MA' is +6 and 'Doe, 

1590 # John MA PhD' +23 -- the member's descent, its lean, 

1591 # and the report below (2026-09-19). 

1592 # 

1593 # The comma has already named the family and the first 

1594 # piece after it is the given name, so the words to 

1595 # spare S4's count asks about are there by 

1596 # construction and the count says nothing at this 

1597 # slot. What is left is the writing, which is the same 

1598 # evidence the comma-less spelling of the same name 

1599 # reads. #144's two-segment restriction below is NOT 

1600 # inherited: it exists because a trailing 'V' before a 

1601 # third comma part is likely a middle initial, and a 

1602 # class member is not initial-shaped while a 

1603 # credential list behind it makes the credential 

1604 # reading likelier rather than less. 

1605 piece = pieces[m] 

1606 # rules/S12: a split credential reads "as a member of the 

1607 # ambiguous class", here read as the class is with no 

1608 # writing to lean on and in silence, as group merges it 

1609 # only behind the given word 

1610 if len(piece) == 2 and "split" in ptags[m]: 

1611 return (m >= trailing_floor(m, titled) 

1612 and not attaches_the_lead(m, titled)) 

1613 if len(piece) == 1: 

1614 tok = tokens[piece[0]] 

1615 if AMBIGUOUS_ACRONYM_TAG in tok.tags: 

1616 # 'ending the given part' reaches past the 

1617 # credentials behind it and past a trailing 

1618 # title, which trailing_floor() skips the way 

1619 # previous_kept() does -- so 'Doe, John MA 

1620 # Prof.' and 'Doe, John Prof. MA' land on one 

1621 # answer without a second notion of trailing. 

1622 # A name word behind the member ends the 

1623 # reach, and the member is an ordinary middle 

1624 # name read in silence. 

1625 if (m >= trailing_floor(m, titled) 

1626 and not attaches_the_lead(m, titled)): 

1627 # A member that is ALSO particle 

1628 # vocabulary reads as the credential only 

1629 # on a POSITIVE credential lean: P6's 

1630 # attachment outranks this reading in 

1631 # every other spelling, and 'Doe, John do' 

1632 # leans nothing, so the positional reading 

1633 # would take it -- the wrong answer there, 

1634 # not merely a stray report 

1635 # (decisions/S5, 2026-09-18). 

1636 # 

1637 # A FUNCTION since #533 rather than a 

1638 # condition written here (mechanisms.md 

1639 # #ONE-PREDICATE-PER-QUESTION). The call 

1640 # costs one frame PER MEMBER asked at this 

1641 # slot, not one per name: against 

1642 # 2f57ff21, 'Doe, John MA' is 310 -> 311 

1643 # and 'Doe, John MA Ma MA', which asks 

1644 # four times, 439 -> 443, while a name 

1645 # with no member here never reaches it 

1646 # ('Smith, John' 206, 'MA JD' 185, both 

1647 # unchanged). Measured 2026-09-19 per 

1648 # `Parser.parse`; Derek took that trade 

1649 # deliberately. 

1650 # #544: an unambiguous credential in front 

1651 # of it in the same run anchors it -- asked 

1652 # through a thunk, so only a member the 

1653 # writing declines pays for the pass 

1654 if credential_at_the_given_slot( 

1655 tok, state.one_case, 

1656 lambda: anchored(m, titled)): 

1657 return True 

1658 prev = previous_kept(m, titled) 

1659 # trailing piece of a two-part name is unambiguously 

1660 # positioned: v1 accepts the lenient test there 

1661 # ('Smith, John V' -> suffix='V', #144); with a third 

1662 # comma part the trailing token is more likely a middle 

1663 # initial, so strict only 

1664 return (m == previous_kept(len(pieces), titled) 

1665 and len(state.segments) == 2 

1666 and len(pieces[m]) == 1 

1667 and _reads_as_a_trailing_suffix( 

1668 pieces[m], pieces[prev], ptags[prev], 

1669 tokens, state.lexicon, state.one_case)) 

1670 

1671 # rules/C1: "anything else after the comma means the 

1672 # listing form" -- and this walk reads it. A segment of no 

1673 # name word never reaches here: it is `_comma.decide`'s, read piece by piece and bound at 

1674 # group's head (#613, rules/C8), the suffix verdict 

1675 # before the title reading of the same word (#296, #325). 

1676 # A name word in the segment makes it v1's walk ('Smith, 

1677 # John Jr.'). 

1678 # rules/H9: "no comma has marked the given name" -- here the 

1679 # family comma has, so the peel reports nothing 

1680 n = _peel_leading_titles(pieces, ptags, tokens, None, 

1681 state.lexicon) 

1682 # rules/H5: "the title is TRANSPARENT to the suffix 

1683 # reading: where two or more name words stand, what 

1684 # stands once the chain is taken reads exactly as it 

1685 # would read written without the title, plus the title" 

1686 # -- the trailing title run, on this walk 

1687 # too. A name word in segment 1 is what keeps 

1688 # `_comma.decide` from reading the segment as the 

1689 # postnominal part, so 'Smith, John Prof.' had no route 

1690 # to title at all and read middle 'Prof.' at every 

1691 # baseline; the 'Smith, Dr.' family of rows are decide's 

1692 # doing, a different mechanism. 

1693 # 

1694 # The candidates are the pieces this walk would NOT 

1695 # read as a suffix, which is this path's answer to the 

1696 # peel the no-comma path runs first -- 'Smith, John 

1697 # Prof. Jr.' must reach `Prof.` past the postnominal 

1698 # behind it, and 'Smith, John Prof. V' past the 

1699 # numeral the lenient tail test claims (#144), which 

1700 # is why the filter is the walk's own predicate and 

1701 # not the strict suffix test alone. Piece `n` is 

1702 # always the given below, whatever that predicate 

1703 # would say of it, so it is always a candidate. That 

1704 # `k == n` is LOAD-BEARING, not defensive: it is what 

1705 # the walk's floor stands on when the given piece 

1706 # itself reads as a suffix, and dropping it leaves 

1707 # 'Smith, II Mr. V' a middle 'Mr.' where the title is 

1708 # (24 inputs of that shape move, of 191,146 generated, 

1709 # measured 2026-09-09). 

1710 # 

1711 # `candidates` is the ORDER, which `trailing_titles` and 

1712 # its slice read; everything after asks MEMBERSHIP, 

1713 # once per piece, and a list or tuple answers that by 

1714 # scanning, so the walk was quadratic in the part's 

1715 # length (#553). Hence the two sets. A tuple keying 

1716 # `floors` or `anchor_memo` above would also re-hash 

1717 # its whole length at every lookup, where a frozenset 

1718 # caches its hash. 

1719 candidates = [k for k in range(n, len(pieces)) 

1720 if k == n 

1721 or not reads_as_a_suffix(k, frozenset())] 

1722 # rules/H8: "the word joins the title field, and the given 

1723 # part reads as it would without it" -- a bare salutation 

1724 # chains here as a period-marked title does 

1725 kept = trailing_titles(candidates, pieces, ptags, tokens, 

1726 salutations=True) 

1727 walkable = frozenset(candidates) 

1728 titled_idx = frozenset(candidates[kept:]) 

1729 for k in titled_idx: 

1730 _set_roles(tokens, pieces[k], Role.TITLE) 

1731 # v1 walk order: the first non-title piece is ALWAYS the 

1732 # given, before any suffix check -- 'Smith, V. Jones' keeps 

1733 # first='V.'. Two 2.x deviations are `_comma.decide`'s 

1734 # reading now (#613), so the walk here never meets 

1735 # either: a last piece 

1736 # that is unambiguously suffix-shaped is a suffix, where v1 

1737 # made it the given ('Andrews, M.D.', 'Smith, Dr. Jr.'; 

1738 # classified fix(comma-family)), a segment whose only 

1739 # non-title piece is a suffix piece holding no name word; 

1740 # and a credential opening the segment makes it the 

1741 # postnominal part whatever follows ('Hardman, RN - CRNA' 

1742 # kept first='RN' until #603). 

1743 if n < len(pieces): 

1744 _set_roles(tokens, pieces[n], Role.GIVEN) 

1745 # The chain's floor leaves a name piece standing, so the 

1746 # given above is never one of the pieces it took. What the 

1747 # chain DOES move is where this segment's name ends, which 

1748 # the lenient tail test turns on (#144) -- so where it took 

1749 # something the question is re-asked with the pieces it 

1750 # left. Where it took nothing, `walkable` already IS this 

1751 # predicate's answer for every piece: the first pass's own 

1752 # memo, not a second spelling of the question, and it 

1753 # cannot have gone stale because nothing the chain does 

1754 # moved the end of the name. 

1755 # rules/S8: "or after the given word in the part after a 

1756 # family comma" -- the given part's own credential run 

1757 # (#602), the comma having fixed the family. The tag tests are inline ahead of the 

1758 # predicate, as in `_pieces.credential_run`, so a name word 

1759 # pays no frame: this loop runs on every family-comma name. 

1760 sticky_from = len(pieces) 

1761 for m in range(n + 1, len(pieces)): 

1762 tags = tokens[pieces[m][0]].tags 

1763 if (m not in titled_idx 

1764 and ("suffix" in ptags[m] or "split" in ptags[m] 

1765 or (("vocab:suffix" in tags 

1766 or AMBIGUOUS_ACRONYM_TAG in tags 

1767 or _ROMAN.match(tokens[pieces[m][0]].text 

1768 .rstrip(".")) is not None) 

1769 and tags.isdisjoint(_NOT_A_RUN_START))) 

1770 and starts_a_credential_run(pieces[m], ptags[m], 

1771 tokens, None, 

1772 state.one_case)): 

1773 sticky_from = m 

1774 break 

1775 # rules/P6: "a particle ending the name attaches to that 

1776 # family name" -- decided here, in the pass that reads the 

1777 # given part, rather than predicted for post_rules to take 

1778 # a stage later (#613). The tail is `pieces[k6:end6]`, found 

1779 # by `particle_tail` ONCE, at the moment the walk reaches 

1780 # #602's run (or, with no run, once the walk has placed 

1781 # every piece): the pieces in front of the run hold the roles 

1782 # this walk gave them and the run's own words hold none 

1783 # yet, which is the state in which the walk's answer equals 

1784 # the one it would give over the finished roles -- the run 

1785 # reads every word it keeps as a suffix or a title, neither 

1786 # a name role, and a class member in it as the credential, 

1787 # where the walk's #531 stop wants it. The tail's words are 

1788 # left to the walk, not the run: absorbing them reported a 

1789 # suffix reading the attachment then overrode ('Smith, John 

1790 # PhD de', 'Smith, John PhD de Jr.'). A particle the walk 

1791 # does not end on -- one with a credential behind it and 

1792 # another particle past that, 'Smith, John PhD de PhD van' 

1793 # -- stays in the run, as does a lone member of the 

1794 # ambiguous credential class (`do`), which the run reads as 

1795 # the credential. 

1796 k6 = end6 = len(pieces) 

1797 

1798 def as_a_middle(m: int, token: WorkToken) -> PendingReading: 

1799 # the name reading of a word the walk read as a suffix: 

1800 # a middle name, taken in by the name word in front, or 

1801 # the family where P1's fold opens the part 

1802 front = pieces[m - 1] 

1803 # P1's fold after a family comma takes the rest of the 

1804 # part into the family whatever the order, so a word past 

1805 # the opening particle's own piece lands there; the word 

1806 # directly behind it is `_name_alt`'s, which takes it into 

1807 # that piece; decisions/C9 (#877) 

1808 if n < m - 1 and is_lone_never_given_particle(pieces[n], 

1809 tokens): 

1810 return name_reading(token, Role.FAMILY) 

1811 return _name_alt( 

1812 token, Role.MIDDLE, 

1813 front if tokens[front[0]].role in NAME_ROLES else None, 

1814 tokens, m - 1 == n, True) 

1815 

1816 for m in range(n + 1, len(pieces)): 

1817 if m == sticky_from: 

1818 k6, end6 = particle_tail(pieces, tokens) 

1819 if m in titled_idx: 

1820 continue 

1821 suffix_here = (reads_as_a_suffix(m, titled_idx) 

1822 if titled_idx else m not in walkable) 

1823 # rules/S10: "A numeral read by its shape alone reports 

1824 # too" -- one of two or more letters the walk reads as 

1825 # the generation; `_ROMAN` first, C-level 

1826 if (suffix_here and len(pieces[m]) == 1 

1827 and len(stem := (tok := tokens[pieces[m][0]]).text 

1828 .rstrip(".")) >= 2 

1829 and _ROMAN.match(stem) is not None 

1830 and not is_listed_numeral(tok.text, tok.tags) 

1831 and not numeral_settled_by_case(tok.text, 

1832 state.one_case)): 

1833 ambiguities.append(PendingAmbiguity( 

1834 AmbiguityKind.SUFFIX_OR_NAME, 

1835 f"{tok.text!r} is a roman numeral, so it reads as " 

1836 f"a generational suffix; it may be a name word", 

1837 (pieces[m][0],), 

1838 # a name word the walk keeps past the given 

1839 # name is a middle one 

1840 alternative=as_a_middle(m, tok))) 

1841 # rules/C9: "a lone letter ending the given part ahead of 

1842 # a further comma is the middle initial, whatever stands 

1843 # before it" -- the further comma has named the suffix, 

1844 # so the credential run in front stops short of it 

1845 # (#819); `len` first, C-level 

1846 if (m > sticky_from and len(state.segments) > 2 

1847 and len(pieces[m]) == 1 

1848 and len(tokens[pieces[m][0]].text.rstrip(".")) == 1 

1849 and m == previous_kept(len(pieces), titled_idx) 

1850 and is_initial_shaped( 

1851 tokens[pieces[m][0]].text.upper())): 

1852 _set_roles(tokens, pieces[m], Role.MIDDLE) 

1853 continue 

1854 # rules/C9: "The bare letter read as the generation behind 

1855 # a name word is reported" -- the shape a middle initial 

1856 # is written in, as S10 reports it with no comma; 

1857 # decisions/C9 (#877); `len` first, C-level 

1858 if (len(pieces[m]) == 1 

1859 and len((tok := tokens[pieces[m][0]]).text) == 1 

1860 and (m >= sticky_from and not k6 <= m < end6 

1861 or m < sticky_from and suffix_here) 

1862 and _ROMAN.match(tok.text) is not None 

1863 and tokens[pieces[m - 1][0]].role in NAME_ROLES 

1864 # a particle in front may take the letter into 

1865 # its chain or stand alone, the field the name 

1866 # reading names depending on which; decisions/C9 

1867 # (#877) 

1868 and not is_prefix_piece(pieces[m - 1], ptags[m - 1], 

1869 tokens) 

1870 and not numeral_settled_by_case(tok.text, 

1871 state.one_case)): 

1872 ambiguities.append(PendingAmbiguity( 

1873 AmbiguityKind.SUFFIX_OR_NAME, 

1874 f"{tok.text!r} is a roman numeral, so it reads as a " 

1875 f"generational suffix; any other single letter " 

1876 f"there would be a middle initial", 

1877 (pieces[m][0],), alternative=as_a_middle(m, tok))) 

1878 if m >= sticky_from and not k6 <= m < end6: 

1879 # inside the run: a title word reads as a title, 

1880 # every other word as a suffix, and a word the walk 

1881 # would have kept as a name is reported, here where 

1882 # the run absorbs it 

1883 if (m > sticky_from 

1884 and not is_suffix_piece(pieces[m], ptags[m], 

1885 tokens) 

1886 and is_title_piece(pieces[m], ptags[m], 

1887 tokens)): 

1888 _set_roles(tokens, pieces[m], Role.TITLE) 

1889 continue 

1890 piece = pieces[m] 

1891 if (len(piece) == 1 

1892 and not tokens[piece[0]].tags.isdisjoint( 

1893 _AMBIGUOUS_CREDENTIAL_TAGS)): 

1894 # a class member inside the run is still the 

1895 # fork #531's report names, now read as the 

1896 # credential, as #544's anchor reads one -- 

1897 # unless its capitals settled it (rules/S6) 

1898 if not settled_by_case(tokens[piece[0]], 

1899 state.one_case): 

1900 ambiguities.append(PendingAmbiguity( 

1901 AmbiguityKind.SUFFIX_OR_NAME, 

1902 f"{tokens[piece[0]].text!r} ending the given " 

1903 f"part is also an ordinary name word; read " 

1904 f"as a credential", (piece[0],), 

1905 alternative=as_a_middle( 

1906 m, tokens[piece[0]]))) 

1907 # the run's own first word is the credential that 

1908 # starts it, a split one ('Pharm D') included 

1909 elif (not suffix_here and m != sticky_from 

1910 and has_name_content(piece, tokens)): 

1911 ambiguities.append( 

1912 _absorbed(piece, tokens, 

1913 as_a_middle(m, tokens[piece[0]]))) 

1914 _set_roles(tokens, piece, Role.SUFFIX) 

1915 continue 

1916 _set_roles(tokens, pieces[m], 

1917 Role.SUFFIX if suffix_here else Role.MIDDLE) 

1918 # #531's report, one of this module's SUFFIX_OR_NAME 

1919 # sites (the header lists them). The gate reads EITHER tag, as the 

1920 # first-post-comma emitter's does: classify writes the 

1921 # shape tag whether or not the dotted switch admits 

1922 # the token, which is what lets a declined fork be 

1923 # reported without being taken. 

1924 # 

1925 # The report tracks the FORK CONSULTED, not the lean 

1926 # (#289's rule), so a member the writing kept as a 

1927 # name reports too -- 'Doe, John Ma' stays a middle 

1928 # name and says so. It reports only in the TRAILING 

1929 # RUN: with a name word behind it no fork was 

1930 # consulted, and AGENTS.md's "a kind is worth adding 

1931 # only if a reader would hesitate too" is why that 

1932 # must stay silent rather than why it happens to (the 

1933 # sentence is the 2.0-conventions section's, not 

1934 # rules/A1's, which this cited until 2026-09-19). 

1935 # 

1936 # The `do` carve-out is a REPORT carve-out on top of 

1937 # the reading one above: a particle-tagged member this 

1938 # walk did not itself take is P6's fork, and P6 

1939 # reports it in its own kind, which is 

1940 # mechanisms.md#AMBIGUITY-AT-THE-DECISION-SITE read 

1941 # strictly. Without it 'Doe, John do' reported both 

1942 # kinds. 

1943 piece = pieces[m] 

1944 if (len(piece) == 1 

1945 and not tokens[piece[0]].tags.isdisjoint( 

1946 _AMBIGUOUS_CREDENTIAL_TAGS) 

1947 and (suffix_here 

1948 or "particle" not in tokens[piece[0]].tags) 

1949 # rules/S6: "a clash settled by the writing is 

1950 # not reported" 

1951 and not (suffix_here and settled_by_case( 

1952 tokens[piece[0]], state.one_case))): 

1953 # The SAME floor the predicate measures members 

1954 # against, so this report has no walk of its own to 

1955 # regress: it had one, and on a run the predicate's 

1956 # memo had already made quadratic the report was 

1957 # CUBIC, because its per-member walk re-scanned 

1958 # `walkable` -- a list -- at every step. Measured 

1959 # then on `'Doe, ' + 'John '*r + 'MA '*r`, r 

1960 # doubling from 100: 5.8/28/175/1229ms, 4.9x then 

1961 # 6.2x then 7.0x per doubling and heading for the 

1962 # 8x a cubic gives, against 1.8/3.7/8.0ms here 

1963 # (2026-09-19). Nothing guarded it: the scan was a 

1964 # C-level `in` over a list and emitted no frame, so 

1965 # the frame-ratio test in tests/v2/test_benchmark.py 

1966 # was structurally blind to it, and the clock-based 

1967 # shapes beside it repeat ONE unit where this cost 

1968 # needs a name holding two runs (`_PREFIXED_SHAPES` 

1969 # adds a prefix but still repeats one run, so it 

1970 # does not reach this either). What keeps it gone 

1971 # is the structure: one walk, read by both callers, 

1972 # so a second would have to be written on purpose. 

1973 # 

1974 # Read once for the whole loop, `run_floor` being 

1975 # where the FIRST member's walk stopped and the 

1976 # floor for every later member too: if the walk 

1977 # reached that member, nothing behind it refuses 

1978 # and no later member can be refused either; if it 

1979 # stopped short, it stopped at the LAST piece that 

1980 # refuses, which is exactly what a later member's 

1981 # own walk would have found (2026-09-19). 

1982 if run_floor < 0: 

1983 run_floor = trailing_floor(m, titled_idx) 

1984 if m >= run_floor: 

1985 i2 = piece[0] 

1986 ambiguities.append(PendingAmbiguity( 

1987 AmbiguityKind.SUFFIX_OR_NAME, 

1988 f"{tokens[i2].text!r} ending the given " 

1989 f"part is also an ordinary name word; " 

1990 f"read as " 

1991 f"{'a credential' if suffix_here else 'a name'}", 

1992 (i2,), 

1993 alternative=( 

1994 as_a_middle(m, tokens[i2]) 

1995 if suffix_here 

1996 else suffix_reading(tokens[i2])))) 

1997 if sticky_from == len(pieces): 

1998 k6, end6 = particle_tail(pieces, tokens) 

1999 # Behind every family comma, one with nothing before it 

2000 # too, which then makes the particle the whole family 

2001 # (', Dr. Jones vd'; decisions/C15, #892). GIVEN ahead of 

2002 # the tail -- rules/P6: "at least one given word must 

2003 # remain" -- the given is piece `n`, so a tail starting 

2004 # there leaves none ('Nguyen, Van'). 

2005 # And not where a never-given particle opens the part: P1's 

2006 # fold takes every given and middle word into the family in 

2007 # post_rules, so no given word remains for this one either 

2008 # ('Smith, de Mesnil van' keeps 'van' where the fold puts 

2009 # it, not hoisted in front of 'Smith', the 2026-08-18 

2010 # defect); with no name word behind it to fold, the tail 

2011 # stays the suffix, behind an empty head as behind its twin 

2012 # (', de PhD vd' as 'Smith, de PhD vd', #892). 

2013 if (n < k6 < end6 

2014 and not is_lone_never_given_particle(pieces[n], 

2015 tokens)): 

2016 _attach_particle_tail(pieces, tokens, n, k6, end6, 

2017 ambiguities) 

2018 # rules/H6: "A run of title words opening the part before a 

2019 # family comma is the title" -- by vocabulary only, never H2's 

2020 # shape, leaving one piece for the family, and stopping at a 

2021 # word that is a particle too ('St Pierre', 'Freiherr von 

2022 # Berg'), or where everything behind it is post-nominal, which 

2023 # leaves no family word to address -- H3's concern, by a 

2024 # stricter test than H3's one-word give-back, so this is not 

2025 # `leading_titles` ('RA PhD Jr., John' keeps family 'RA', where 

2026 # 'RA PhD Jr. John' reads title 'RA'). A dual is a title wherever 

2027 # a name word follows it ('Rev. Sr. Smith, Mary'). 

2028 # `is_title_piece` is H5's vocabulary read, so the two slots that 

2029 # refuse the shape agree on what a title word is, save H5's 

2030 # written-short clause, which this slot does not read 

2031 # (decisions/H9, #999: 'Maj. Smith, John' keeps family 'Maj. Smith'). 

2032 # 

2033 # rules/H10: "where a title the run keeps stands behind it" -- 

2034 # a dotted run ('U.S. Senator Smith, John') counts as a title 

2035 # word here as in the leading run, and one the run ends on is 

2036 # given back ('U.S. Grant Smith, John' keeps family 'U.S. Grant 

2037 # Smith'), so the roles are set once the run is known. 

2038 lead = 0 

2039 floor = len(fam_pieces) 

2040 # the length test and the tag test first, both C-level: a part 

2041 # no title opens ('Smith, John', 'Smith Jones, John') pays no 

2042 # frame. "title" and "vocab:title" are the two arms 

2043 # `is_title_piece` answers yes from, so for a listed title the 

2044 # predicate stays the one that answers; a lone piece carrying 

2045 # classify's DOTTED_TITLE_TAG is taken on the tag alone 

2046 # (rules/H10) and given back below unless a title the run keeps 

2047 # stands behind it 

2048 tags0 = tokens[fam_pieces[0][0]].tags if fam_pieces else frozenset() 

2049 if (floor > 1 

2050 and ("title" in fam_tags[0] or "vocab:title" in tags0 

2051 or DOTTED_TITLE_TAG in tags0) 

2052 and ((len(fam_pieces[0]) == 1 and DOTTED_TITLE_TAG in tags0) 

2053 or is_title_piece(fam_pieces[0], fam_tags[0], tokens))): 

2054 # where the trailing post-nominal run starts: walked once, 

2055 # and only for a part a title opens 

2056 while floor > 1 and is_suffix_piece(fam_pieces[floor - 1], 

2057 fam_tags[floor - 1], 

2058 tokens): 

2059 floor -= 1 

2060 # the first piece is a title or a dotted run, asked above; a 

2061 # dotted run the run ends on is given back after the walk 

2062 while (lead + 1 < floor 

2063 and (lead == 0 or is_title_piece( 

2064 fam_pieces[lead], fam_tags[lead], tokens) 

2065 or (len(fam_pieces[lead]) == 1 and DOTTED_TITLE_TAG 

2066 in tokens[fam_pieces[lead][0]].tags)) 

2067 and "particle" not in tokens[fam_pieces[lead][0]].tags): 

2068 lead += 1 

2069 while (lead and len(fam_pieces[lead - 1]) == 1 

2070 and DOTTED_TITLE_TAG 

2071 in tokens[fam_pieces[lead - 1][0]].tags): 

2072 lead -= 1 

2073 for k in range(lead): 

2074 _set_roles(tokens, fam_pieces[k], Role.TITLE) 

2075 # rules/P9: "a particle that is also suffix vocabulary heads 

2076 # the name word behind it" -- with a name piece behind it in 

2077 # this part (vd, mc), it heads that name, as P2's join does, 

2078 # and is not peeled from between two family words 

2079 # (#573: 'SMITH VD MA, JOHN', where the uniform case left the 

2080 # chain stopped before VD; 'SMITH VD JR, JOHN', a suffix word 

2081 # behind it, keeps suffix 'VD JR'). Read here because only 

2082 # this family pass needs it: a comma followed by no name 

2083 # word behind a whole name is group's suffix comma since #613, 

2084 # and the NO_COMMA read takes segment 0, trailing run and all 

2085 # ('Berg de MA, Prof.'). The first piece the title run leaves 

2086 # is the family's first and is family whatever it is, as piece 

2087 # 0 is with no title ('Dr. PhD Smith, John' as 'PhD Smith, John'). 

2088 # A piece at or past the floor is already known to be a suffix 

2089 # with nothing but suffixes behind it, so it is not asked again. 

2090 

2091 # Whether a particle read as family stands in front of the 

2092 # piece the loop is at, and whether the piece right in front was 

2093 # read as family, for P2's arm below. 

2094 chained = False 

2095 family_before = False 

2096 for k in range(lead, len(fam_pieces)): 

2097 piece = fam_pieces[k] 

2098 if k >= floor: 

2099 _set_roles(tokens, piece, Role.SUFFIX) 

2100 # rules/C1: "a numeral ending the family part is read as the 

2101 # comma-less name reads it" -- the numeral's predicate, a 

2102 # one-letter numeral with two name words in front of it 

2103 # (rules/S10; name words only, `_names_before`), so 'John 

2104 # Smith V, M.J.' keeps suffix 'V' as 'John Smith III, M.J.' 

2105 # keeps 'III' (#774). `_ROMAN` 

2106 # first, C-level, so a family word pays no frame 

2107 elif (k > lead and len(piece) == 1 

2108 and _ROMAN.match( 

2109 (word := tokens[piece[0]]).text.rstrip(".")) 

2110 is not None 

2111 and all(is_suffix_piece(fam_pieces[j], fam_tags[j], 

2112 tokens) 

2113 for j in range(k + 1, len(fam_pieces))) 

2114 and (numeral := _family_part_numeral( 

2115 word, tokens[fam_pieces[k - 1][0]].text, 

2116 _names_before(fam_pieces, tokens, lead, k), 

2117 state.one_case)) is not None): 

2118 _set_roles(tokens, piece, numeral) 

2119 # reported where taken: a name word here is the family 

2120 # name the comma has just named, which nobody misreads 

2121 if (numeral is Role.SUFFIX 

2122 and not is_listed_numeral(word.text, word.tags) 

2123 and not numeral_settled_by_case(word.text, 

2124 state.one_case)): 

2125 ambiguities.append(PendingAmbiguity( 

2126 AmbiguityKind.SUFFIX_OR_NAME, 

2127 f"{word.text!r} ending the family part is a roman " 

2128 f"numeral, so it reads as a generational suffix; " 

2129 f"it may be a name word", tuple(piece), 

2130 alternative=name_reading(word, Role.FAMILY))) 

2131 elif (k > lead and is_suffix_piece(piece, fam_tags[k], tokens) 

2132 and not (k + 1 < len(fam_pieces) 

2133 and "particle" in tokens[piece[0]].tags 

2134 and not is_suffix_piece( 

2135 fam_pieces[k + 1], fam_tags[k + 1], 

2136 tokens)) 

2137 # rules/P2: "before a family comma the part keeps 

2138 # either in the family behind a particle, as the 

2139 # chain does" -- a suffix word S8 starts no run at 

2140 # or declines for its writing, anything but 

2141 # suffixes behind it ('bin Abd Rahman, Ahmad', 

2142 # 'bin Md Rahman, Ahmad'); a particle that is suffix 

2143 # vocabulary too is P9's (decisions/P2, #1177) 

2144 and not (chained and family_before 

2145 and "particle" not in tokens[piece[0]].tags 

2146 and not all( 

2147 is_suffix_piece(fam_pieces[j], fam_tags[j], 

2148 tokens) 

2149 for j in range(k + 1, len(fam_pieces))) 

2150 and not starts_the_run_here( 

2151 k, fam_pieces, fam_tags, tokens, 

2152 state.one_case, False))): 

2153 _set_roles(tokens, piece, Role.SUFFIX) 

2154 else: 

2155 _set_roles(tokens, piece, Role.FAMILY) 

2156 # the tag tests are is_prefix_piece's own arms, inline 

2157 # so a family word pays no frame 

2158 chained = chained or ("prefix" in fam_tags[k] 

2159 or len(piece) == 1 

2160 and "particle" 

2161 in tokens[piece[0]].tags) 

2162 family_before = tokens[piece[0]].role is Role.FAMILY 

2163 tail = 2 

2164 # rules/C2: segments past the structure's name segments are 

2165 # consumed as suffixes, except "a word of the title vocabulary there 

2166 # that is not also suffix vocabulary is a title" (#603) -- a part 

2167 # past the second comma holds no name word, so the surnames H5 

2168 # keeps a bare trailing title word from misreading are not there to 

2169 # protect ('Eric H. Holder, Jr., Attorney General'). A 

2170 # piece holding a suffix word stays the postnominal the slot makes 

2171 # it, a dual alone as after a family comma (C8) and a dual group's 

2172 # connective join took into a title ('PhD - and MD') alike. 

2173 for seg_idx in range(tail, len(state.segments)): 

2174 for piece, ptags_ in zip(state.pieces[seg_idx], 

2175 state.piece_tags[seg_idx]): 

2176 # the tag tests inline ahead of the predicate: every tail 

2177 # past the name segments runs this ('John Smith, Jr., PhD'), 

2178 # the part after a suffix comma being bound by group, and the call 

2179 # would be its frame. Both arms are needed -- a connective 

2180 # join tags a piece a title though its first token is none 

2181 # ('and Secretary of State') 

2182 _set_roles(tokens, piece, 

2183 Role.TITLE 

2184 if (("title" in ptags_ 

2185 or "vocab:title" in tokens[piece[0]].tags) 

2186 and is_title_piece(piece, ptags_, tokens) 

2187 and not any("vocab:suffix" in tokens[i].tags 

2188 for i in piece)) 

2189 else Role.SUFFIX) 

2190 # rules/S12: "where the count takes the pair, the reading is reported" -- at 

2191 # the end of a name, at the end of the given part, as a run start, 

2192 # here where its roles are final (the part a comma's decision binds reports in `_comma`, a 

2193 # pair a maiden clause releases in group). A loop over the piece 

2194 # tags, so a name with no pair pays no frame for it. 

2195 for seg_idx in range(min(tail, len(state.segments))): 

2196 for piece, ptags_ in zip(state.pieces[seg_idx], 

2197 state.piece_tags[seg_idx]): 

2198 if ("split" in ptags_ and len(piece) == 2 

2199 and tokens[piece[0]].role is Role.SUFFIX 

2200 and tokens[piece[1]].role is Role.SUFFIX): 

2201 ambiguities.append(split_credential_report( 

2202 piece, tokens, _split_name_alt(state, tokens, seg_idx, 

2203 piece, order))) 

2204 # the reports earlier stages left a field for: only a name that 

2205 # has one pays the walk 

2206 if state.ambiguities: 

2207 _resolve_deferred(state, tokens, ambiguities, order) 

2208 return copy_with(state, tokens=tuple(tokens), 

2209 order=order, 

2210 ambiguities=tuple(ambiguities))