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

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

436 statements  

1"""Stage: group. 

2 

3Consumes: tokens (classified), segments, structure, one_case, extracted 

4(the role + inner span per delimited region, for the #329 pass below; 

5post_rules reads it too, for S13); and, through 

6`_comma.decide`, comma_offsets (the own-words span of the name's case 

7contrast) and dropped, which decide extends. 

8Produces: structure -- its head decides a comma form once the words 

9are tagged (rules/C1, #613, `_comma.decide`), binding the SUFFIX and 

10TITLE roles of a postnominal part after the comma, emptying its 

11segment and dropping its delimiter cores before any join, plus the SUFFIX_OR_NAME 

12ambiguities that decision reports, those the particle chain and a 

13maiden clause report on a trailing member they take into the name 

14(an ambiguous acronym, or since #718 a declined numeral), and the 

15caps shape's acronym tags on 

16a bound all-caps word (#564); pieces + piece_tags per segment 

17(runs of token indices -- tokens are NEVER joined into strings: the 

18anti-#100 invariant); maiden tail tokens get role=MAIDEN, and the 

19trailing run a maiden take gives up gets its SUFFIX and TITLE roles 

20here (#601), so no join can reach it; marker tokens land in dropped; 

21and tail_reads, rules/S2's trailing run of each main segment read 

22ONCE, after P3's connective joins and before the particle chain and 

23the bound-given join, and split off so neither reaches it (#614), 

24which assign places and reports rather than peeling again; and, 

25last in each part, P12's join of a listed pair past the start, the 

26classify tag left only on the bound word of a pair it joined (#694). 

27Reads: token tags (from classify), Lexicon.given_name_titles (the 

28P5 licence, #369), Policy.name_order (rules/P13's joins at the end 

29of a name read given-first, #1098) and Policy.extra_suffix_delimiters, whose 

30delimiter-core tokens part a tail segment as a comma would and are 

31dropped (v1 suffix_delimiter parity, #549), as the built-in cores' 

32are (rules/C10, #889); and, through 

33`_comma.decide`, what that module's own header lists 

34(Policy.lenient_comma_suffixes and Policy.unlisted_caps_suffixes among 

35them). The v1 

36"derived titles/prefixes" registration becomes piece_tags entries -- 

37per-parse state that dissolves with the state (v1 kept per-parse sets 

38for the same reason). 

39 

40Implements rules P2, P3, P4, P12 and M2, S2's trailing read (#614), C1's 

41decision through _comma, and 

42the 

43group half of M1 (#329: the marker dropped inside EXTRACTED maiden 

44content, which M2's pieces walk cannot reach because extract's 

45content never enters pieces); each is cited at its code below. Also 

46implements rule P5 (cited below at the bound-given join) and ports 

47the "Ph. D."-split merge (v1 fix_phd; decisions/phd-merge). 

48 

49The piece-level predicates moved to _pieces in #439 -- the S2 

50trailing peel, the leading-title and title-piece tests, the 

51suffix-piece test, the no-name-segment test. Most are shared with 

52assign; trailing_start is group's alone and travelled because the 

53shared ones call it (is_title_piece was too, until #602 and #603 gave 

54assign calls of its own). They had collected here 

55by import direction rather than by topic (assign imported group and 

56could not be imported back), which is the accumulation 

57mechanisms.md#ONE-PREDICATE-PER-QUESTION describes; group imports them 

58back like any other caller, and still does the work H3 and S2 describe 

59with them. is_prefix_piece and is_conj_piece followed in #617, with 

60the merge and rules/P3's connective joins (join_connectives), when 

61the comma decision's own copy of those joins was folded into them. 

62What remains defined here is group's own: _is_rootname and 

63_is_maiden_marker_piece. 

64""" 

65from __future__ import annotations 

66 

67import bisect 

68from collections.abc import Iterable, Sequence, Set 

69from enum import IntEnum 

70from typing import assert_never 

71 

72from nameparser._lexicon import _run_addresses_by_given 

73from nameparser._pipeline._pieces import ( 

74 split_credential_report, 

75 Peel, TailRead, chain_lead, chain_run_end, is_conj_piece, is_leading_title, is_prefix_piece, 

76 is_suffix_piece, is_title_piece, is_trailing_title_word, 

77 join_connectives, joined_tags, 

78 leading_titles, merge_particle_runs, merge_pieces, peel_walk, 

79 read_trailing_run, settled_by_case, tail_reading, trailing_candidates, 

80 trailing_run_start, trailing_start, 

81 trailing_start_past_titles, 

82) 

83from nameparser._pipeline._state import ( 

84 AMBIGUOUS_ACRONYM_TAG, BOUND_PHRASE_TAG, SPLIT_CREDENTIAL_TAG, 

85 SPLIT_MEMBER_TAG, TRAILING_TITLE_TAG, ParseState, 

86 Deferred, PendingAmbiguity, Structure, WorkToken, 

87 _AMBIGUOUS_CREDENTIAL_TAGS, copy_with, 

88) 

89import nameparser._pipeline._comma as _comma 

90from nameparser._pipeline._vocab import ( 

91 ROMAN, comma_follows_marker, delimiter_cores, ends_in_a_generation, 

92 composed, name_reading, numeral_settled_by_case, 

93 reads_as_a_reported_numeral, suffix_reading, 

94) 

95from nameparser._types import AmbiguityKind, Role 

96 

97# the split credential is `_vocab.split_pair`, which classify asks of 

98# every adjacent pair and writes on the pair's first word, and which 

99# is_wholly_suffix asks to merge the same Ph./D. pair, as 

100# `_comma._pieces` reads the tag -- so that predicate's callers read 

101# what this stage merges: `_comma.decide`'s reading of the part after 

102# the comma, segment's C2 flag on a tail part, and script_segment's 

103# decline of a wholly-suffix post-comma run, all of which see the 

104# merged reading 

105 

106Piece = list[int] 

107#: What the marker pass took out of a segment: the marker's TOKEN 

108#: indices (more than one where the entry is a phrase, 'z domu'), and 

109#: every other piece it took with the role that piece takes -- the 

110#: maiden name's Role.MAIDEN, and the trailing run's SUFFIX or TITLE 

111#: (#601). `_group_segment` produces it. 

112MaidenTake = tuple[Piece, list[tuple[Piece, Role]]] 

113#: What `_maiden_take` answers with, one index space further out: PIECE 

114#: indices into its own `pieces` argument. `_group_segment` resolves 

115#: them to the token indices MaidenTake declares, which is the only 

116#: reason the two are different types rather than one name used twice. 

117MaidenIndices = tuple[list[int], list[tuple[int, Role]]] 

118 

119 

120class ClauseSite(IntEnum): 

121 """Where a maiden clause stands, which decides whether a marker 

122 counts there at all and which rule reads the end of the part 

123 (rules/M2, #601). 

124 

125 A marker counts only in the part holding the family name, so 

126 GIVEN_SLOT and TAIL are statements, not defaults: a marker there is 

127 an ordinary word. FAMILY_PART is the part before a family comma, 

128 where a marker counts but no trailing rule reads the words after 

129 the clause. 

130 

131 A CLOSED set: `_maiden_take` dispatches on it exhaustively, ONCE 

132 (`typing.assert_never`). group() is the one place (structure, 

133 segment index) is mapped onto it, and tests/v2/pipeline/ 

134 test_group.py's `test_the_site_is_pinned_to_the_structure_it_is_ 

135 read_from` pins that mapping -- by watching the call, not by 

136 restating it -- along with this enum's size. 

137 """ 

138 

139 FAMILY_PART = 0 # FAMILY_COMMA segment 0: a marker counts, nothing trails 

140 TRAILING = 1 # NO_COMMA, SUFFIX_COMMA segment 0: S2 peel + H5 chain 

141 GIVEN_SLOT = 2 # FAMILY_COMMA segment 1: a marker is an ordinary word 

142 TAIL = 3 # every tail segment: a marker is an ordinary word 

143 

144 

145class BoundJoin(IntEnum): 

146 """v1 _join_bound_first_name's reserve_last, as the three states it 

147 actually has. IntEnum: the value IS the number of name pieces 

148 assign's peel must leave in the JOINED view for the join to stand 

149 (#425), so the >= comparison below reads unchanged. Post-comma the 

150 reserve runs no peel -- the pair alone is that one piece; the 

151 decline reads H5's trailing run only where a title word follows 

152 the bound word (#637) -- and DISABLED 

153 is a mode, never compared: as a threshold 0 would join everything, 

154 which is why the block is entered on identity first.""" 

155 

156 DISABLED = 0 # the FAMILY_COMMA family segment (v1 never joined it) 

157 LENIENT = 1 # FAMILY_COMMA's post-comma segment (reserve_last=False) 

158 STRICT = 2 # main segments (reserve_last=True: keep a family piece) 

159 

160 

161# rules/M2: "A marker that counts takes the words after it as the 

162# maiden name, and is itself dropped" -- where a marker counts, and up 

163# to what, are `_maiden_take`'s (history: decisions/M2) 

164# 

165# A marker piece is a LONE marker -- M2's When ("a recognized maiden 

166# marker stands as a word in a name"). The consumer runs before every join but the Ph. D. merge 

167# (see _group_segment), so a marker inside a wider piece is one the 

168# consumer left there: declined ("Jane van der Berg née" reads family 

169# 'van der Berg née'), or never examined (a leading marker, or a second 

170# marker behind the span it took). Matching inside joined pieces would 

171# re-read those. 

172# 

173# With the pass ahead of the joins no default-vocabulary input reaches 

174# the lone-piece half through the one caller left that sees joined 

175# pieces (P5's marker decline, on `fk + 1`): a marker-headed wider 

176# piece needs a connective right after a declined marker, and a 

177# connective after a marker is a word the consumer takes. Measured at 

178# #420's review -- 

179# dropping `len(piece) == 1` leaves the suite and a 337k-name sweep 

180# identical -- so it stays as the rule's definition, not as a guard a 

181# pin holds. 

182def _is_maiden_marker_piece(piece: Sequence[int], 

183 tokens: Sequence[WorkToken]) -> bool: 

184 return (len(piece) == 1 

185 and "vocab:maiden-marker" in tokens[piece[0]].tags) 

186 

187 

188# mechanisms.md#ONE-PREDICATE-PER-QUESTION: "where the reader comes 

189# AFTER the decider, record the answer on the state instead" -- which is 

190# what the marker tags are. classify owns the phrase lookahead; group, 

191# running later, reads what it wrote. 

192def marker_run_length(following: Iterable[Set[str]]) -> int: 

193 """How many tokens a marker run spans, given the tag sets of what 

194 FOLLOWS its head, in order: 1 plus the leading continuations. 

195 

196 One walk for both places group asks -- over pieces below and over 

197 token indices in the clause drop at the end of this module, 700 

198 lines apart and differing only in index space. `following` is 

199 consumed lazily and only until the run ends, so a caller passes a 

200 generator over the rest of its sequence rather than materializing 

201 one.""" 

202 run = 1 

203 for tags in following: 

204 if "vocab:maiden-marker-cont" not in tags: 

205 break 

206 run += 1 

207 return run 

208 

209 

210def _marker_run_pieces(pieces: Sequence[Sequence[int]], 

211 tokens: Sequence[WorkToken], m: int) -> int: 

212 """How many pieces the marker run at pieces[m] spans: 1 

213 for a single-word marker, more for a phrase entry ('z domu'). 

214 

215 classify already decided where the run ends and recorded it on the 

216 tokens -- "vocab:maiden-marker" on the head, 

217 "vocab:maiden-marker-cont" on the rest. 

218 

219 Each continuation is the NEXT piece, and 

220 that holds because classify REFUSES to tag a run whose tokens are 

221 not structurally contiguous. It is not a property of this walk, and 

222 the reasons a token can be absent between two pieces are wider 

223 than they look. No join has run yet, so a piece is one token. A 

224 tail segment is cut at its delimiter cores and grouped part by 

225 part (#549), and a core between two marker words would be a token 

226 between them, which classify would not have tagged as a run. And 

227 `pieces` comes from a SEGMENT, or a part of one, and segment keeps 

228 only the tokens no stage has given a role, bucketed by the commas 

229 before them -- so a run half inside a bracketed clause, or split across a 

230 structure comma, is one no segment holds whole. Walking cont tags 

231 without classify's refusal read a proper PREFIX of the phrase as 

232 the whole marker, and 'Anna z (domu) Nowak' lost its given name to 

233 a bare preposition. Any future stage that removes a token from a 

234 segment owes this the same refusal. 

235 

236 What classify guarantees is exactly the main stream's half of that 

237 and no more: a run of ROLE-LESS tokens stays inside one segment. It 

238 can also tag a run across two adjacent clauses of the same role, 

239 which this walk never sees because a role-bearing token is in no 

240 segment at all -- see _vocab.tag_marker_runs, which states the limit. 

241 """ 

242 return marker_run_length( 

243 tokens[pieces[k][0]].tags for k in range(m + 1, len(pieces))) 

244 

245 

246def _maiden_take(pieces: Sequence[Sequence[int]], 

247 ptags: Sequence[Set[str]], 

248 tokens: Sequence[WorkToken], 

249 one_case: bool | None, 

250 site: ClauseSite, 

251 ambiguities: list[PendingAmbiguity], 

252 ) -> MaidenIndices | None: 

253 """The PIECE indices the marker pass removes, split the way 

254 MaidenIndices declares them: the marker's pieces (one, or several 

255 for a phrase entry like 'z domu'), then the maiden name's and the 

256 trailing run's, each with the role it takes. None where the marker is 

257 an ordinary word. Piece indices into `pieces`, not the TOKEN 

258 indices `_group_segment` builds out of them. 

259 

260 Three questions, in order (rules/M2, #601): 

261 

262 * WHERE A MARKER COUNTS. Only in the part holding the family name 

263 -- never in the given part after a family comma, never in a tail 

264 part -- and only with a name word straight before it: not a word 

265 of the leading title run (H3's give-back included, so 'Lord 

266 Chancellor née Jones' counts; a lone title is a title), not a 

267 suffix word unless it opens the name (S2), not a connective. A 

268 particle counts: with nothing after it to attach to it is no 

269 particle there ('Mai Le née Nguyen'). 

270 * WHAT IT TAKES. Every word after it up to the trailing run the 

271 name reads as if the clause were not written, and always the 

272 first word after it. The run is read over the VIEW -- the head 

273 plus the vocabulary-shaped tail, and anything from the first 

274 credential that starts a run (S8, #602) -- by `tail_reading`, so 

275 the count of words to spare is the clause-free name's. Before a 

276 family comma no trailing rule reads the part, and the family 

277 part takes a suffix word from anywhere in it: the clause stops 

278 at the first one and hands the rest back unbound. 

279 * THE RUN IS BOUND. Where a trailing rule reads the part, the take 

280 consumes the run and returns the role the reading gave each 

281 piece, so no join below this pass can reach into it. 

282 

283 Computed before any join (the Ph. D. merge aside), as the marker 

284 pass always has been (#420): the joins see a name without the 

285 clause in it. 

286 """ 

287 if site is ClauseSite.GIVEN_SLOT or site is ClauseSite.TAIL: 

288 return None 

289 m = next((v for v in range(1, len(pieces)) 

290 if _is_maiden_marker_piece(pieces[v], tokens)), None) 

291 if m is None: 

292 return None 

293 # the marker may be a phrase, in which case it is several pieces 

294 lo = m + _marker_run_pieces(pieces, tokens, m) 

295 if lo >= len(pieces): 

296 return None 

297 before = m - 1 

298 lead = (1 if m == 1 and is_title_piece(pieces[0], ptags[0], tokens) 

299 else leading_titles(pieces[:m], ptags[:m], tokens)) 

300 if (before < lead 

301 or (before > lead 

302 and is_suffix_piece(pieces[before], ptags[before], tokens)) 

303 or is_conj_piece(pieces[before], ptags[before], tokens)): 

304 return None 

305 # a suffix word straight after the marker: nothing was announced 

306 if is_suffix_piece(pieces[lo], ptags[lo], tokens): 

307 return None 

308 if site is ClauseSite.FAMILY_PART: 

309 end = next((j for j in range(lo + 1, len(pieces)) 

310 if "suffix" in ptags[j] 

311 or is_suffix_piece(pieces[j], ptags[j], tokens)), 

312 len(pieces)) 

313 return list(range(m, lo)), [(j, Role.MAIDEN) 

314 for j in range(lo, end)] 

315 if site is not ClauseSite.TRAILING: 

316 assert_never(site) 

317 c = trailing_candidates(lo, pieces, ptags, tokens) 

318 end = len(pieces) 

319 released: list[tuple[int, Role]] = [] 

320 # the view's picks, for the kept member below (#718) 

321 picked: tuple[tuple[int, ...], ...] = () 

322 # nothing behind the clause that a trailing rule could take: the 

323 # view would be the head alone, and the whole rest is the clause 

324 if c < len(pieces): 

325 left = list(range(m)) + list(range(c, len(pieces))) 

326 view = [pieces[i] for i in left] 

327 vtags = [ptags[i] for i in left] 

328 vrest, vchained, vpeel = tail_reading( 

329 peel_walk(leading_titles(view, vtags, tokens), vtags), 

330 view, vtags, tokens, one_case) 

331 picked = vpeel.picks 

332 titles = set(vchained) | set(vpeel.run_titles) 

333 in_run = titles | set(vrest[vpeel.names:]) | { 

334 q for q in range(m, len(view)) if "suffix" in vtags[q]} 

335 e = len(view) 

336 while e - 1 >= m and (e - 1) in in_run: 

337 e -= 1 

338 end = c + (e - m) 

339 released = [(j, Role.TITLE if m + (j - c) in titles 

340 else Role.SUFFIX) for j in range(end, len(pieces))] 

341 # rules/S12: "where the count takes the pair, the reading is reported" -- 

342 # the clause-free reading took it, and the take binds it here 

343 for j, role in released: 

344 if "split" in ptags[j] and role is Role.SUFFIX: 

345 ambiguities.append( 

346 split_credential_report( 

347 pieces[j], tokens, 

348 name_reading(tokens[pieces[j][0]], Role.MAIDEN))) 

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

350 # site that takes the branch, not where an ambiguous tag sits" 

351 # -- the take consumed the run, so the forks the reading called 

352 # on it are reported here, where assign will never see them 

353 consumed = {i for j in range(end, len(pieces)) for i in pieces[j]} 

354 for piece in (*vpeel.picks, *vpeel.absorbed, 

355 *vpeel.numerals): 

356 if piece[0] in consumed: 

357 ambiguities.append(PendingAmbiguity( 

358 AmbiguityKind.SUFFIX_OR_NAME, 

359 f"{tokens[piece[0]].text!r} after the maiden name " 

360 f"reads as a suffix; it may be a name word", 

361 tuple(piece), 

362 # a name word there ends the clause's maiden name 

363 alternative=name_reading(tokens[piece[0]], 

364 Role.MAIDEN))) 

365 # ...and the member the clause KEEPS as its last word: the reading 

366 # left it a name word, which is a fork too 

367 last = pieces[end - 1] 

368 word = tokens[last[0]] 

369 # a one-word member: a split credential the clause keeps is one the 

370 # count declined, which reports nothing (rules/S12), and its first 

371 # word is not the clause's last 

372 if len(last) == 1 and not word.tags.isdisjoint(_AMBIGUOUS_CREDENTIAL_TAGS): 

373 ambiguities.append(PendingAmbiguity( 

374 AmbiguityKind.SUFFIX_OR_NAME, 

375 f"{word.text!r} ending the maiden name is also a " 

376 f"post-nominal; the maiden marker's clause keeps it rather " 

377 f"than reading it as one", tuple(last), 

378 alternative=suffix_reading(word))) 

379 elif tuple(last) in picked: 

380 # rules/S10: "either reading is reported" -- a numeral the read 

381 # declined, for want of words to spare or for its Title case, 

382 # which the clause keeps 

383 why = ("but the name has no other word to spare for it" 

384 if len(word.text.rstrip(".")) == 1 

385 else "but written in Title case it leans to the name") 

386 ambiguities.append(PendingAmbiguity( 

387 AmbiguityKind.SUFFIX_OR_NAME, 

388 f"{word.text!r} ending the maiden name is a roman numeral, " 

389 f"{why}; the maiden marker's clause keeps it rather than " 

390 f"reading it as a generational suffix", tuple(last), 

391 alternative=suffix_reading(word))) 

392 elif (released and len(last) == 1 and end - 2 >= lo 

393 and reads_as_a_reported_numeral( 

394 word.text, word.tags, tokens[pieces[end - 2][0]].text, 

395 one_case)): 

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

397 # family name, and the reading is reported" -- the clause's 

398 # twin of assign's report (#721's review): the clause keeps the 

399 # numeral its released post-nominal stands behind 

400 ambiguities.append(PendingAmbiguity( 

401 AmbiguityKind.SUFFIX_OR_NAME, 

402 f"{word.text!r} ending the maiden name is a roman numeral, " 

403 f"but a post-nominal follows it; the maiden marker's clause " 

404 f"keeps it rather than reading it as a generational suffix", 

405 tuple(last), alternative=suffix_reading(word))) 

406 return (list(range(m, lo)), 

407 [(j, Role.MAIDEN) for j in range(lo, end)] + released) 

408 

409 

410#: What `_between_name_words` reads instead of walking: two arrays over 

411#: the segment's pieces, giving for each index the nearest piece on 

412#: its left and on its right that is NOT a connective -- `-1` and 

413#: `len(pieces)` where the run reaches the end. Built by 

414#: `_run_neighbours`, and an ALIAS rather than a NamedTuple because a 

415#: NamedTuple's __new__ is a frame of its own (decisions/parse-cost). 

416_Beside = tuple[list[int], list[int]] 

417 

418 

419def _run_neighbours(pieces: Sequence[Sequence[int]], 

420 ptags: Sequence[Set[str]], 

421 tokens: Sequence[WorkToken]) -> _Beside: 

422 """The nearest piece that is not a connective, on each side of 

423 every index. 

424 

425 EVERY MEMBER OF ONE RUN HAS THE SAME ANSWER, which is the whole 

426 of the fix: `_between_name_words` used to walk the run itself, so a 

427 name holding a run of n connectives walked it n times and the 

428 stage went quadratic in the run's length -- measured 2026-09-20, 

429 `"Josep " + "i " * n + "Rovira"` grew 3.8x per doubling against 

430 the 2.0x every other shape holds, 59ms at n=800. Two linear 

431 passes answer the same question once for the whole segment. 

432 `tests/v2/test_benchmark.py`'s `link_run` shape is the guard. 

433 

434 `is_conj_piece` is asked ONCE per piece, into a list the second 

435 pass then reads: asking it in both passes would double the calls, 

436 and it is the only per-piece call this builder makes. Recorded in 

437 the FIRST pass rather than by a comprehension of its own -- which 

438 is a code object on 3.11 and a bytecode saving here rather than a 

439 frame one, measured: the profile hook emits no call event for it. 

440 

441 WHAT IT COSTS A SHORT NAME, because answering for the whole 

442 segment is not free where the walk would have stopped at once: 

443 this call, plus `is_conj_piece` for the pieces the walk never 

444 reached. Re-measured 2026-09-21 on py3.11 against b9ed1429, the 

445 whole pair through `tests/v2/test_benchmark.py`'s own 

446 `_frames_for` shape -- `Josep Carod i Rovira` 311 -> 313 frames 

447 (one call and two more `is_conj_piece` over its four pieces, 

448 less the frame the fold below saved) and `Jane Doe nee Puig i 

449 Soler` 315 -> 319. An O(1) rise per link-bearing name against an 

450 unbounded saving: the same name with a run of 64 links goes 

451 6,741 -> 2,587. (The pair first written here read 304 -> 307 and 

452 307 -> 312, with 6,741 -> 2,652 for the run of 64. Re-measured 

453 2026-09-22 on py3.11, WHICH OF THOSE REPRODUCE is: the two 

454 short-name pairs, neither of them; the run-of-64 pair, its left 

455 half only -- b9ed1429 reads 6,741 exactly, while 6048eb5d, the 

456 tree the 2,652 was taken on, reads 2,651 here. So the interpreter 

457 splice ran through a single arrow, which is what the table 

458 `tools/perf/call_count.py`'s own docstring warns about. Every 

459 figure above is one interpreter, stated.) 

460 `tools/perf/call_count.py` is unmoved (parse=406.00, 

461 facade=443.00) -- its reference name carries no link -- and so are 

462 `John Smith`, `Smith, John`, `Juan Garcia y Lopez` and `Jane Doe 

463 nee Smith`, none of which reaches this at all. 

464 

465 Called only where a generational connective was found in the 

466 segment (the `frozen` loop), so no ordinary name pays for it at 

467 all. 

468 

469 Dropping either pass's `not` fails 

470 test_a_connective_piece_counts_toward_the_carve_outs_total 

471 (mutation-checked 2026-09-20; how the two arrays are READ is 

472 checked in `_between_name_words`, which reads them). 

473 """ 

474 n = len(pieces) 

475 left = [-1] * n 

476 right = [n] * n 

477 conj = [False] * n 

478 prev = -1 

479 for k in range(n): 

480 left[k] = prev 

481 is_conj = is_conj_piece(pieces[k], ptags[k], tokens) 

482 conj[k] = is_conj 

483 if not is_conj: 

484 prev = k 

485 nxt = n 

486 for k in range(n - 1, -1, -1): 

487 right[k] = nxt 

488 if not conj[k]: 

489 nxt = k 

490 return left, right 

491 

492 

493def _is_rootname(piece: Sequence[int], ptags: Set[str], 

494 tokens: Sequence[WorkToken]) -> bool: 

495 if len(piece) == 1 and "initial" in tokens[piece[0]].tags: 

496 return False 

497 # rules/P3: "A connective counts as a name word wherever this 

498 # rule counts them, whatever else the vocabulary says the word is" 

499 # (#397). The order against the `initial` refusal above decides 

500 # nothing, and what makes that safe lives in classify rather than 

501 # here: for a single letter it writes `initial` or `conjunction` 

502 # and never both, so a one-case `I`/`i` arrives with no conjunction 

503 # tag whichever test runs first -- measured, hoisting this arm 

504 # above the refusal moves no field, report or initial on any 

505 # corpus name under three name orders, and no test. INLINE rather 

506 # than a call to is_conj_piece: this runs once per piece of every 

507 # name (frame budget). 

508 if ("conjunction" in ptags 

509 or (len(piece) == 1 and "conjunction" in tokens[piece[0]].tags)): 

510 return True 

511 return not (is_title_piece(piece, ptags, tokens) 

512 or is_prefix_piece(piece, ptags, tokens) 

513 or is_suffix_piece(piece, ptags, tokens)) 

514 

515 

516def _is_lone_letter(piece: Sequence[int], tokens: Sequence[WorkToken], 

517 one_case: bool | None) -> bool: 

518 """A piece of one word that is one letter, with or without its 

519 period: the letter P13 links. Not a connective, the word P13 asks 

520 about, nor a lowercase letter in a name of mixed case, which S10 

521 reads as the generation by its writing.""" 

522 if len(piece) != 1 or "conjunction" in tokens[piece[0]].tags: 

523 return False 

524 letter = composed(tokens[piece[0]].text.rstrip(".")) 

525 return (len(letter) == 1 and letter.isalpha() 

526 and not numeral_settled_by_case(letter, one_case)) 

527 

528 

529def _is_name_word(piece: Sequence[int], ptags: Set[str], 

530 tokens: Sequence[WorkToken]) -> bool: 

531 return not (is_conj_piece(piece, ptags, tokens) 

532 or is_title_piece(piece, ptags, tokens) 

533 or is_suffix_piece(piece, ptags, tokens)) 

534 

535 

536def _link_the_last_letter(pieces: list[list[int]], ptags: list[set[str]], 

537 tokens: Sequence[WorkToken], 

538 one_case: bool | None, keep_a_name: bool) -> None: 

539 """P13's two joins at the end of the part holding the family name, 

540 made ahead of P3's carve-out. `keep_a_name` marks the whole name's 

541 part, where a piece past the title run must stand in front of the 

542 join, so the name keeps a word for its given name. The connective 

543 is looked for before the title run is counted, so a part with no 

544 connective where P13 looks pays only the walk back from the end, 

545 not leading_titles' count.""" 

546 # rules/P13: "A single-letter connective in front of a lone letter 

547 # ending the name words joins it to the word before" and "A 

548 # connective with nothing to link joins the word before it alone" 

549 # -- both joins, made ahead of P3's carve-out (decisions/P13 

550 # (#1097, #1098)) 

551 e = len(pieces) - 1 

552 lone = False 

553 while e >= 0: 

554 piece = pieces[e] 

555 if _is_lone_letter(piece, tokens, one_case): 

556 lone = True 

557 break 

558 # rules/P13: "with nothing behind it but suffixes or a 

559 # trailing title H5 reads" -- H5's own word test, asked rather 

560 # than copied, so the walk steps over every title the chain 

561 # takes (decisions/P13, #1168) 

562 if not (is_trailing_title_word(piece, tokens) 

563 or is_suffix_piece(piece, ptags[e], tokens) 

564 or (e > 0 and len(piece) == 1 

565 # reads_as_generation's own first test, asked at C 

566 # level as _comma._whole_name and _group_segment's 

567 # P3 numeral count ask it, so a word that is no 

568 # numeral pays no frame for the call 

569 and ROMAN.match(tokens[piece[0]].text.rstrip(".")) 

570 is not None 

571 and ends_in_a_generation( 

572 [tokens[pieces[e - 1][-1]].text, 

573 tokens[piece[0]].text], one_case))): 

574 break 

575 e -= 1 

576 if e < 1: 

577 return 

578 # the connective: in front of the lone letter, or the word the walk 

579 # stopped at 

580 at = e - 1 if lone else e 

581 if (at < 1 or len(pieces[at]) != 1 

582 or "conjunction" not in tokens[pieces[at][0]].tags): 

583 return 

584 word = tokens[pieces[at][0]] 

585 text = composed(word.text) 

586 if lone: 

587 if not (len(text) == 1 and text.isalpha()): 

588 return 

589 elif not (text.isalpha() 

590 and not (one_case and len(text) == 1 

591 and text.lower() != text.upper()) 

592 and "vocab:suffix" not in word.tags 

593 and not (len(text) > 1 and e + 1 < len(pieces) 

594 and is_trailing_title_word(pieces[e + 1], tokens))): 

595 return 

596 if (at - 1 >= leading_titles(pieces, ptags, tokens) + keep_a_name 

597 and _is_name_word(pieces[at - 1], ptags[at - 1], tokens)): 

598 merge_pieces(pieces, ptags, at - 1, e + 1) 

599 

600 

601# rules/P3: "a word the rest of the parse reads as a name word 

602# rather than as a generation, a credential or an honorific, looked 

603# for past any run of connectives standing between. A connective with 

604# nothing to its right is connecting nothing, and a word of that 

605# vocabulary ending a name, or standing before the credential a name 

606# ends with, is the generation it also spells" (#397) -- the WHOLE 

607# clause, its second sentence included, because that sentence is what 

608# this predicate answering `False` means. Reading it as a generation 

609# is the CALLER's half: `_group_segment`'s `frozen` set is where a 

610# connective this refuses is placed as the generation. 

611# `Sequence[Sequence[int]]` rather than `Sequence[Piece]`: this reads a 

612# piece and never edits one (widened when the maiden walk was a 

613# second caller, before #601). 

614def _between_name_words(k: int, lo: int, hi: int, 

615 pieces: Sequence[Sequence[int]], 

616 ptags: Sequence[Set[str]], 

617 tokens: Sequence[WorkToken], 

618 beside: _Beside) -> bool: 

619 """Whether such a word stands on EACH side of the connective piece 

620 at `k`. 

621 

622 Both sides in one call because neither caller ever wants one: a 

623 connective is placed to join only where a name word stands on both 

624 sides of it, so the two answers were always ANDed at the call site 

625 and the left one short-circuits the right either way. One frame per 

626 generational connective rather than two, which is the whole of the 

627 saving -- the left arm below is the old left call and the right arm 

628 the old right one, unchanged (#397 follow-up). 

629 

630 `lo` and `hi` bound the name's own words: assign peels the pieces 

631 below `lo` as its leading titles and those from `hi` up as its 

632 trailing suffix run, so a piece outside that span is a credential 

633 or an honorific however it is spelled, and the numeral or bare 

634 acronym the peel takes ('i V', 'i MA') is inside `hi` by 

635 construction rather than by a second reading of the vocabulary 

636 (mechanisms.md#ONE-PREDICATE-PER-QUESTION). 

637 

638 Inside the span the suffix and title tests still run, because 

639 neither bound reaches a credential or an honorific standing in 

640 the MIDDLE of a name ('Josep Jr. i Rovira', 'Josep Dr. i 

641 Rovira'): the peel walks from the end and stops at the first name 

642 word, the title run from the front. 

643 

644 The answer steps over connectives because a RUN of them joins as 

645 one ('Carod i y Rovira'), so the word this rule is about is the 

646 first one past the run -- and where the run runs out ('Juan i e') 

647 there is no name word on that side at all. `beside` is where that 

648 stepping already happened: 

649 `_run_neighbours` walked every run once for the whole segment, so 

650 this reads an index rather than walking to it. A SENTINEL OUT OF 

651 RANGE is how "the run ran out" arrives -- 

652 -1 on the left, `len(pieces)` on the right -- and each bound test 

653 below turns it into False, exactly as the walk did when it ran 

654 here and stopped at the same place. `lo` is never negative and 

655 `hi` never past `len(pieces)`, so neither sentinel can pass the 

656 bound, and the piece tests are never asked about an index that is 

657 not one. 

658 

659 Mutation-checked 2026-09-21, every arm of both sides by a NAMED 

660 test. Reading the LEFT array for the right arm too fails 

661 test_a_connective_with_nothing_to_its_right_does_not_join; 

662 the RIGHT array for the left arm too fails 

663 test_a_leading_title_on_the_left_is_no_name_word. 

664 The bounds, WIDENED to the whole segment rather than dropped -- 

665 dropping the right one indexes past the pieces on the sentinel and 

666 raises instead of failing a test -- fail 

667 test_the_marker_is_not_the_name_word_on_the_links_left (left) and 

668 test_the_right_hand_test_reads_the_peel_not_the_suffix_piece 

669 (right). Either piece test on the LEFT fails 

670 test_a_credential_or_honorific_mid_name_is_no_name_word_either; 

671 either on the RIGHT fails 

672 test_a_credential_or_honorific_mid_name_on_the_right_too, which is 

673 the row the fold asked for: while this was two per-side calls a 

674 mutation hit both sides at once and the left-hand rows covered for 

675 the right-hand ones, whose own rows stand at the END of the name 

676 where `hi` refuses them first. SWAPPING the two arrays outright is 

677 an EQUIVALENT mutant and no test fails: the two arms are ANDed, so 

678 which array answers which side is not a question the conjunction 

679 can see. 

680 """ 

681 left = beside[0][k] 

682 if not (lo <= left < hi 

683 and not is_suffix_piece(pieces[left], ptags[left], tokens) 

684 and not is_title_piece(pieces[left], ptags[left], tokens)): 

685 return False 

686 right = beside[1][k] 

687 return (lo <= right < hi 

688 and not is_suffix_piece(pieces[right], ptags[right], tokens) 

689 and not is_title_piece(pieces[right], ptags[right], tokens)) 

690 

691 

692# rules/S12: "where the count declines it, the pair is the two words 

693# it is written as" -- after the trailing read, which is where the 

694# count is asked in a main segment 

695def _settle_splits(pieces: list[Piece], ptags: list[set[str]]) -> None: 

696 """Split every merged split credential left in `pieces` back into 

697 its two words, in place: a pair the trailing read declined reads 

698 as it reads unmerged. One a connective join took into a wider piece 

699 keeps the join and loses the tag (the only shape that reaches it 

700 is a pair beside a connective, 'Pharm D and').""" 

701 k = 0 

702 while k < len(pieces): 

703 if "split" in ptags[k]: 

704 if len(pieces[k]) == 2 and ptags[k] == {"split"}: 

705 a, b = pieces[k] 

706 pieces[k:k + 1] = [[a], [b]] 

707 ptags[k:k + 1] = [set(), set()] 

708 k += 2 

709 continue 

710 ptags[k] = ptags[k] - {"split"} 

711 k += 1 

712 

713 

714# rules/P12: "the two words are one name unit past the first name word" 

715# -- past the piece P5's leading join reads, which this leaves as it 

716# was, its reserve included; classify's tag says the word and the 

717# token after it are a listed phrase (history: decisions/P12, #694) 

718def _join_bound_phrases(pieces: list[Piece], ptags: list[set[str]], 

719 tokens: Sequence[WorkToken], 

720 joined_heads: set[int]) -> None: 

721 # built in one pass: a merge per pair would shift every piece 

722 # behind it, a quadratic in a run of pairs (merge_pieces' note) 

723 k = leading_titles(pieces, ptags, tokens) + 1 

724 out, out_tags = pieces[:k], ptags[:k] 

725 n = len(pieces) 

726 joined = False 

727 while k < n: 

728 word = pieces[k] 

729 if (k + 1 < n and len(word) == 1 and not ptags[k] 

730 and BOUND_PHRASE_TAG in tokens[word[0]].tags 

731 and pieces[k + 1] == [word[0] + 1] and not ptags[k + 1]): 

732 joined_heads.add(word[0]) 

733 word.extend(pieces[k + 1]) 

734 out.append(word) 

735 out_tags.append(set()) 

736 joined = True 

737 k += 2 

738 continue 

739 out.append(word) 

740 out_tags.append(ptags[k]) 

741 k += 1 

742 if joined: 

743 pieces[:] = out 

744 ptags[:] = out_tags 

745 

746 

747def _group_segment(seg: tuple[int, ...], additional: int, 

748 tokens: Sequence[WorkToken], 

749 bound_join: BoundJoin = BoundJoin.STRICT, 

750 ambiguities: list[PendingAmbiguity] | None = None, 

751 given_name_titles: Set[str] = frozenset(), 

752 opens_the_name: bool = False, 

753 *, 

754 one_case: bool | None, 

755 site: ClauseSite, 

756 reads: list[TailRead | None] | None = None, 

757 phrase_heads: set[int] | None = None, 

758 given_first: bool, 

759 ) -> tuple[list[Piece], list[set[str]], MaidenTake | None]: 

760 pieces: list[Piece] = [[i] for i in seg] 

761 ptags: list[set[str]] = [set() for _ in seg] 

762 # Out-parameter, same shape as _assign_main: forks are reported 

763 # where they are decided. A caller that passes None (or a throwaway 

764 # list) suppresses reporting -- see group() for when that applies. 

765 if ambiguities is None: 

766 ambiguities = [] 

767 # The maiden take reports on this same channel: it reports only at 

768 # the TRAILING site, which group() never suppresses. 

769 

770 # ph-d merge first: "Ph." "D." adjacent -> one suffix piece, 

771 # where the lexicon lists `phd` as unambiguous suffix vocabulary 

772 # (#1000) 

773 # (decisions/phd-merge; v1 fix_phd did this by regex on the 

774 # raw string) 

775 # A suffix never BEGINS a name: position outranks the vocabulary 

776 # match (#371). This merge is what makes a leading "Ph." "D." a 

777 # credential at all -- every other suffix-shaped word standing 

778 # first already falls out as a title (H2's abbreviation clause, or 

779 # TITLES membership) or as a name word, so the pair is the only 

780 # shape that reaches the defect, and it reached it by emptying the 

781 # family: "Ph. D. Van Johnson" read given 'Van Johnson' with no 

782 # surname at all. 

783 # 

784 # WHERE THE INPUT BEGINS, and that is v1's own boundary rather 

785 # than a new one: fix_phd was a regex requiring a preceding space 

786 # (`\s(ph\.?\s+d\.?)`), so it could never fire at the head of the 

787 # string and fired everywhere else -- mid-name as readily as 

788 # trailing. Three tests, because "the head" has three meanings 

789 # here and only one of them is v1's: 

790 # `k == 0` the first PIECE, which is not the first word -- 

791 # extract_delimited removes a bracketed or quoted 

792 # clause before segment runs, so `"Bob" Ph. D. John 

793 # Smith` reaches this with the pair at k == 0 and a 

794 # word standing before it in the input. v1 merges 

795 # there; declining broke parity on 112 measured 

796 # names, every one opening with a quote or bracket. 

797 # `a[0] == 0` the first TOKEN, which is v1's boundary. 

798 # seg_idx 0 and not after a family comma: a credential run 

799 # legitimately opens segment 1 ("Smith, Ph. D. 

800 # Jr.", C8's credential run in the listing form), and segment 0 before a 

801 # comma is the family the comma named, where v1 

802 # merges too ("Ph. D., John" reads last 'Ph. D.'). 

803 # 

804 # A leading TITLE is therefore NOT stepped over, unlike the #367 

805 # scan below, and the two disagree on purpose: that scan asks 

806 # where the NAME begins, this asks where the STRING does. `Sir 

807 # Ph. D. Van Johnson` keeps suffix 'Ph. D.' with an empty family, 

808 # which is #371's symptom surviving one word to the left -- 1.4.0 

809 # parity, stated as a boundary in rules/S2 rather than fixed 

810 # here, because "the first piece of the name" cannot be computed 

811 # before this merge: `is_leading_title` is true of `Ph.` itself, 

812 # so the scan would step over the very piece being judged. 

813 # 

814 # Every other split credential ('Pharm. D.', `_vocab.split_pair`'s 

815 # "split" answer) merges under the same head test as a member of 

816 # the ambiguous class, where a rule reads such a member: in a main 

817 # segment, where S2's read below decides it by the count, kept 

818 # whole where the read takes it and split back into its two words 

819 # where it does not (`_settle_splits`), so a declined pair reads 

820 # exactly as the two words read; and in the given part after a 

821 # family comma behind the given word, where S5's walk reads it, 

822 # merged ahead of the joins so the particle chain stops in front of 

823 # it and P5's bound join declines it as it declines a suffix word, 

824 # and a pair declined there is middle-name text whole or split. 

825 # (A connective beside a pair keeps it from merging at all, in 

826 # classify.) 

827 # Not in the part before a family comma, which reads no member as 

828 # a suffix ('Smith MA, John'). Classify tagged each pair's first 

829 # word, so the test here is a tag. 

830 # 

831 # Written into fresh lists in one pass, `pos` being the index the 

832 # piece stands at once the merges before it are made: a merge in 

833 # place shifts the rest of the list (`merge_pieces`' comment), and 

834 # a run of pairs would shift it once per pair. A merged pair is 

835 # never a member of another, so the scan moves past both words. 

836 splits = False 

837 given_from: int | None = None 

838 out: list[Piece] = [] 

839 out_tags: list[set[str]] = [] 

840 n = len(pieces) 

841 k = 0 

842 while k < n: 

843 a = pieces[k] 

844 add: str | None = None 

845 if k + 1 < n: 

846 b = pieces[k + 1] 

847 tags = tokens[a[0]].tags 

848 pos = len(out) 

849 if (len(a) == 1 and len(b) == 1 

850 and not (opens_the_name and pos == 0 and a[0] == 0)): 

851 if SPLIT_CREDENTIAL_TAG in tags: 

852 add = "suffix" 

853 elif SPLIT_MEMBER_TAG in tags: 

854 if site is ClauseSite.TRAILING: 

855 add = "split" 

856 splits = True 

857 elif site is ClauseSite.GIVEN_SLOT: 

858 if given_from is None: 

859 given_from = leading_titles( 

860 out + pieces[k:], out_tags + ptags[k:], 

861 tokens) 

862 if pos > given_from: 

863 add = "split" 

864 if add is None: 

865 out.append(a) 

866 out_tags.append(ptags[k]) 

867 k += 1 

868 continue 

869 a.extend(pieces[k + 1]) 

870 out.append(a) 

871 out_tags.append(joined_tags(ptags, k, k + 2, add={add})) 

872 k += 2 

873 pieces[:] = out 

874 ptags[:] = out_tags 

875 

876 # rules/M2: "A marker that counts takes the words after it as 

877 # the maiden name, and is itself dropped" (history: 

878 # decisions/M2) -- the marker pass (#274), and it runs 

879 # BEFORE every join below. Each join rule asks a question about the 

880 # name -- how many words it has (P3's carve-out, P5's reserve), 

881 # what the word after a particle or a bound word is -- and the 

882 # marker and the maiden name are not part of that name: they leave. 

883 # Asked while they were still pieces, the count answered for a name 

884 # that would not exist ('juan y garcia nee jones' counted five, 'y' 

885 # joined, and the family was empty once the clause left: #418), and 

886 # a join could absorb the marker before the pass looked for a lone 

887 # one ('Jane van der Berg née y Jones' kept the marker in the 

888 # family: #412). Removing the pieces first makes both impossible by 

889 # construction, with no stop on the chain (#399, #417) and no 

890 # exclusion in the reserve (#411) left to keep in step. 

891 # 

892 # The tokens are not touched here: this function reads them and 

893 # returns what it took, and group() records the drop and the roles. 

894 taken: MaidenTake | None = None 

895 take = _maiden_take(pieces, ptags, tokens, one_case, 

896 site, ambiguities) 

897 if take is not None: 

898 marker_ks, removed = take 

899 taken = ([i for k in marker_ks for i in pieces[k]], 

900 [(pieces[k], role) for k, role in removed]) 

901 for k in reversed(marker_ks + [k for k, _ in removed]): 

902 del pieces[k] 

903 del ptags[k] 

904 

905 # S2's trailing run, read once below once the connectives have 

906 # joined (#614) 

907 read: TailRead | None = None 

908 tail_pieces: list[Piece] = [] 

909 tail_ptags: list[set[str]] = [] 

910 # too few pieces for the read: a split credential has no words to 

911 # spare (rules/S4) 

912 if splits and len(pieces) + additional < 3: 

913 _settle_splits(pieces, ptags) 

914 if len(pieces) + additional >= 3: 

915 # the part holding the family name -- the end of the name 

916 # only under a given-first order -- and only where a one-word 

917 # connective stands: a plain loop, no frame, for a name with none 

918 if ((site is ClauseSite.TRAILING and given_first) 

919 or site is ClauseSite.FAMILY_PART): 

920 for piece in pieces: 

921 if (len(piece) == 1 

922 and "conjunction" in tokens[piece[0]].tags): 

923 _link_the_last_letter( 

924 pieces, ptags, tokens, one_case, 

925 keep_a_name=site is ClauseSite.TRAILING) 

926 break 

927 # rules/P3: "A connective that is also generational 

928 # vocabulary joins only where a name word stands on each side 

929 # of it" (#397). `frozen` holds the TOKEN index of every such 

930 # connective missing a name word on one side: it is the 

931 # generation it also spells, so no join of its own reaches it, 

932 # it may not merge into a run, and it does not count toward 

933 # the carve-out total. A token index because the joins below 

934 # move piece indices. A neighbour's join can still absorb it: 

935 # `join_connectives`' neighbour-join loop skips a frozen piece 

936 # only as the join's subject. 

937 # 

938 # Asked here, of the pieces as classify left them and of the 

939 # neighbours' class, because after a merge the absorbed suffix 

940 # IS the word on the right. No length test: the clause names a 

941 # class, not a spelling, while the three-word carve-out below 

942 # stays single-letter because its own sentence says so. Only a 

943 # connective of the suffix vocabulary reaches the body, so a 

944 # name with none pays no call for this test. decisions/P3. 

945 frozen: set[int] = set() 

946 # whether any connective is left free to join: a segment with 

947 # none skips the rootname count and the shared loop, both of 

948 # which then do nothing (0 of 3,153 such calls changed a piece 

949 # over the corpora and case table, #617's /simplify), as 

950 # `_comma._reading_pieces` skips it for the part after a comma 

951 free = False 

952 lo = hi = -1 

953 beside: _Beside = ([], []) 

954 for k, piece in enumerate(pieces): 

955 tok = tokens[piece[0]] 

956 if len(piece) != 1 or "conjunction" not in tok.tags: 

957 continue 

958 if "vocab:suffix" not in tok.tags: 

959 free = True 

960 continue 

961 # The bounds and the run memo, computed once per segment 

962 # and only where such a connective was found, so the cost 

963 # is the link's own and no ordinary name pays any of it. 

964 if hi < 0: 

965 lo = leading_titles(pieces, ptags, tokens) 

966 # H5's reading and not the peel over the pieces as 

967 # WRITTEN: a title standing behind the suffix run 

968 # hides it from `trailing_start`, which then answers 

969 # `len(pieces)` and hands this loop a credential as 

970 # the name word on the link's right. 'John Quincy 

971 # Adams i MA Prof.' joined to family 'Adams i MA' with 

972 # no report at all, where 'John Quincy Adams i MA' -- 

973 # the same name, one title shorter -- reads family 

974 # 'Adams', suffix 'i MA' and reports the acronym 

975 # (#397 second review). 

976 hi = trailing_start_past_titles(lo, pieces, ptags, 

977 tokens, 

978 one_case=one_case) 

979 # ONE ANSWER PER RUN: every member of a contiguous run 

980 # of connectives has the same nearest name word on 

981 # each side, and asking per member walked the run once 

982 # per member -- quadratic in its length, 3.8x per 

983 # doubling measured at `b9ed1429`. 

984 beside = _run_neighbours(pieces, ptags, tokens) 

985 if _between_name_words(k, lo, hi, pieces, ptags, tokens, 

986 beside): 

987 free = True 

988 else: 

989 frozen.add(piece[0]) 

990 if free: 

991 total = sum(_is_rootname(p, t, tokens) 

992 for p, t in zip(pieces, ptags) 

993 if p[0] not in frozen) + additional 

994 # rules/P3: "a numeral ending the name, or standing before 

995 # the listed suffix words a name ends with, that the trailing fork 

996 # reads as the generation is no word of the three, as a 

997 # listed one is not" -- the C3 count's question, asked by 

998 # its predicate; decisions/C3 (#893). The suffix words are 

999 # stepped over by their tag and the shape tested by a 

1000 # C-level match, so a name ending in a word pays no frame; 

1001 # the walk stops short of every listed suffix word, a listed 

1002 # numeral included, so `last` is never one 

1003 e = len(pieces) - 1 

1004 while (e > 0 and len(pieces[e]) == 1 

1005 and "vocab:suffix" in tokens[pieces[e][0]].tags): 

1006 e -= 1 

1007 last = tokens[pieces[e][0]] 

1008 if (e > 0 and len(pieces[e]) == 1 

1009 and ROMAN.match(last.text.rstrip(".")) is not None 

1010 and pieces[e][0] not in frozen 

1011 and ends_in_a_generation( 

1012 [tokens[pieces[e - 1][-1]].text, last.text], 

1013 one_case)): 

1014 total -= 1 

1015 # The threshold of P3's carve-out ("except a single-letter 

1016 # connective in a three-word name, which stays a name word") 

1017 # reads the ROOTNAME count, and since #397 a connective counts 

1018 # ITSELF toward that count WHERE IT IS JOINING, so a connective 

1019 # that is also suffix vocabulary no longer raises the bar for 

1020 # its own join and no longer lowers it for an unrelated one 

1021 # ("Carod y Rovira i" counted the trailing generation and let 

1022 # the `y` join). 

1023 join_connectives(pieces, ptags, tokens, letter_stays=total < 4, 

1024 titles_only=False, frozen=frozen) 

1025 # rules/S2's trailing run is read ONCE, here -- after P3's 

1026 # connective joins, which do not depend on it and make the 

1027 # units it counts, and before the particle chain and the 

1028 # bound-given join, which do -- and bound (#614): the run is 

1029 # split off, the chain and the join group the name in front of 

1030 # it, and assign places the run off this reading. Group had 

1031 # asked the peel before and after those joins -- the chain's 

1032 # stop and its re-ask after its own merges, the reserve's two 

1033 # views -- because they change the pieces the count reads; the 

1034 # read counts the units the chain will build instead 

1035 # (_pieces.read_trailing_run), so there is nothing to re-ask, 

1036 # and no join takes a word the run took ('John van der Berg 

1037 # Prof.' keeps its title). The main segments only (no comma, 

1038 # or the part before a suffix comma); a family comma's parts, a 

1039 # part past a comma and a segment of fewer than three pieces -- 

1040 # which no join reaches anyway -- read as before, and so does a 

1041 # run holding a name piece (_pieces.read_trailing_run). 

1042 # The particles straight behind each other are merged first, 

1043 # into the one piece the chain would open its run with, so the 

1044 # read counts and absorbs the run as that piece (#625); 

1045 # `premerged` keeps which runs the merge made, for the chain's 

1046 # report below. 

1047 premerged: Set[int] = frozenset() 

1048 if site is ClauseSite.TRAILING: 

1049 n = leading_titles(pieces, ptags, tokens) 

1050 premerged, lead = merge_particle_runs(pieces, ptags, tokens, n) 

1051 found = read_trailing_run(pieces, ptags, tokens, one_case, n, 

1052 lead) 

1053 if found is not None: 

1054 read, start = found 

1055 tail_pieces, tail_ptags = pieces[start:], ptags[start:] 

1056 del pieces[start:] 

1057 del ptags[start:] 

1058 # the read has decided every split credential: one it took 

1059 # left with the run, and one still here is two name words 

1060 if splits: 

1061 _settle_splits(pieces, ptags) 

1062 # rules/S2: "a trailing word of the suffix vocabulary reads as a 

1063 # suffix" -- on a main segment the run read above decides it 

1064 # and is split off already; elsewhere the chain stops where 

1065 # assign's peel begins (`trailing_run_start`, trailing titles 

1066 # included, in the given part after a family comma; else 

1067 # `trailing_start`; the H5 paragraph below explains the 

1068 # titles). decisions/P2 (#424). 

1069 # rules/P2: "a particle joins the words after it into one name 

1070 # part, the join running until the next particle starts a group of 

1071 # its own, a trailing suffix begins" -- and on to the maiden marker 

1072 # (M2) or the name's end; the final group reads as the family name, 

1073 # earlier groups by position. (history: decisions/P2) 

1074 # rules/P4: "a particle in the name's leading position chains 

1075 # nothing: the words stay separate" (history: decisions/P2) 

1076 # 

1077 # "Leading" is the first piece of the NAME, past assign's own 

1078 # title run (`leading_titles`), unless a word in that run is 

1079 # also a particle: then the last such word leads in the run's 

1080 # place (`chain_lead`, decisions/P2 (#624)). Computed once, 

1081 # before the loop: every merge below starts past this index, so 

1082 # none can move it. Suffix pieces are deliberately not skipped 

1083 # when finding the leading piece (decisions/P2). A maiden 

1084 # marker still here is one the consumer declined, an ordinary 

1085 # word to the chain. decisions/M2 (#417). 

1086 name_start = leading_titles(pieces, ptags, tokens) 

1087 leading = chain_lead(pieces, ptags, tokens, name_start) 

1088 # rules/P2: "a trailing suffix begins" -- where it begins 

1089 # is read by assign's peel over the pieces as they stand 

1090 # (#424), once per segment and kept as a length from the end, 

1091 # which the chain's merges ahead of it do not move -- except 

1092 # where a particle that is suffix vocabulary too (vd, mc, do) 

1093 # starts the run: the prefix run below takes it as a particle, 

1094 # as P6 reads it after a comma, and 'John van Mc' keeps family 

1095 # 'van Mc' (every baseline's reading). A suffix WORD stops the 

1096 # chain where S8 would start its run at it, a word S8 starts no 

1097 # run at ('bin Abd Rahman') or declines for its writing with a 

1098 # name word behind it ('binti Md Salleh') being the chain's 

1099 # (#1177), and the trailing 

1100 # run -- the numeral, or the bare acronym with words to spare 

1101 # -- stops it where the suffix-piece test alone did not ('John 

1102 # van der Berg V' read family 'van der Berg V'). The chain 

1103 # takes both forks, and where no run was read ahead of it it 

1104 # asks again after its merges whether the acronym still has 

1105 # the pieces the fork counted (below). 

1106 # the run read above is already split off (#614), so the chain 

1107 # stops at the end of what is left. 

1108 # rules/H5: "the title is TRANSPARENT to the suffix reading" 

1109 # -- in the given part after a family comma too, where assign's 

1110 # walk reads the trailing title run, so the chain stops before 

1111 # the run of suffixes and titles `tail_reading` takes there 

1112 # rather than before the suffixes alone, and does not carry a 

1113 # trailing title into the particle's name ('Smith, John van der 

1114 # Berg Prof.' reads title 'Prof.' as 'Smith, John Prof.' does, 

1115 # #623). The bound matters only where a particle stands past the 

1116 # chain's lead, so it is not asked otherwise: the chain then 

1117 # merges nothing (the test is `is_prefix_piece`'s, inline). 

1118 # rules/C2: "a part past the second comma holds no name word" 

1119 # -- assign reads such a part wholly as suffixes and its title 

1120 # words as titles, so the chain builds no name unit there and is 

1121 # not run, and a title word behind a particle run stays a word 

1122 # C2 can read ('John Smith, Jr., van der Berg Prof.', #636). The 

1123 # part after a suffix comma reaches no chain either: `_comma. 

1124 # decide` binds it and empties its segment before any join 

1125 # (#613). 

1126 chains = False 

1127 if read is None and site is not ClauseSite.TAIL: 

1128 for k in range(leading + 1, len(pieces)): 

1129 if ("prefix" in ptags[k] 

1130 or len(pieces[k]) == 1 

1131 and "particle" in tokens[pieces[k][0]].tags): 

1132 chains = True 

1133 break 

1134 given_slot = site is ClauseSite.GIVEN_SLOT 

1135 tail = (0 if not chains 

1136 else len(pieces) - ( 

1137 trailing_run_start(name_start, pieces, ptags, tokens, 

1138 one_case) if given_slot 

1139 else trailing_start(name_start, pieces, ptags, tokens, 

1140 one_case=one_case))) 

1141 # the members the chain reports below, which the read's picks 

1142 # then give up (#614) 

1143 took: set[tuple[int, ...]] = set() 

1144 

1145 def chain(tail: int) -> None: 

1146 # `pieces[:titled]` are known to be leading titles. A merge 

1147 # from k to j changes only indices from k on, and k only 

1148 # grows, so a piece behind k is final and its answer can 

1149 # be kept: the 

1150 # cursor makes the all-titles-ahead test below one walk per 

1151 # chain rather than one per chain site (#559). 

1152 titled = 0 

1153 k = 0 

1154 while k < len(pieces): 

1155 if k <= leading or not is_prefix_piece(pieces[k], 

1156 ptags[k], tokens): 

1157 k += 1 

1158 continue 

1159 j = chain_run_end(k, pieces, ptags, tokens, 

1160 len(pieces) - tail, one_case, 

1161 given_slot) 

1162 # The other half of PARTICLE_OR_GIVEN. _assign reports the 

1163 # fork when an ambiguous particle stays a lone leading piece 

1164 # ("Van Johnson" -> given under the default order, family 

1165 # under FAMILY_FIRST); the chain here takes the opposite 

1166 # branch when the particle is not the name's leading piece. 

1167 # A fork whose two sides are decided in different stages 

1168 # needs an emitter in each. 

1169 # 

1170 # Narrow, and #367 is why. `titled == k` says every 

1171 # piece ahead of this one is a title, and the loop 

1172 # skipped every piece up to `leading`, so `leading` is 

1173 # STRICTLY before k -- one of those titles, and, being 

1174 # before the title run's end, a prefix as well 

1175 # (`chain_lead`): a word in both vocabularies (`st`, 

1176 # `freiherr` and the rank particles by default, or any overlap a caller 

1177 # configures). A plain title alone can no longer put a 

1178 # particle off the name's leading piece; it is stepped 

1179 # over and _assign reports the fork instead. 

1180 # 

1181 # What that leaves is wider than one shape: any number of 

1182 # plain title pieces, then a piece in BOTH vocabularies, 

1183 # then any number of further titles, then the ambiguous 

1184 # particle whose chain claims something. "Freiherr von 

1185 # Richthofen" is the canonical spelling and the one 

1186 # tests/v2/cases.py and tests/v2/test_parser.py lead with, 

1187 # but "St Van Johnson", "Do St Johnson" (the chained 

1188 # particle itself in both vocabularies) and "Dr. Do van 

1189 # Johnson" (a plain title AHEAD of the both-vocabulary 

1190 # word) all reach here too. What none of them can do is 

1191 # dispense with the both-vocabulary WORD. The conjunction 

1192 # merge is the only other way a piece acquires `title` or 

1193 # `prefix`, and it cannot manufacture the pair: it derives 

1194 # from ONE neighbor, which is the left one whenever there 

1195 # is a left one, and its right operands are always fresh 

1196 # pieces (the loop runs left to right, so nothing to the 

1197 # right has been merged yet). Both tags therefore have to 

1198 # come from the piece it extends, which bottoms out at a 

1199 # lone token in both vocabularies. 

1200 # 

1201 # j > k + 1 is what makes this a DECISION rather than a 

1202 # shape: when the chain stops at the next piece (the 

1203 # trailing run, a suffix word S8 would start its run 

1204 # at, or a suffix-vocabulary particle's stop) the inner 

1205 # scan never advances, the merge from k to k+1 folds a 

1206 # piece into itself, and the particle stays a lone 

1207 # leading piece -- nothing 

1208 # was chained, and _assign reports that case instead. 

1209 # Without this the two emitters both fire on the same token. 

1210 # A run merged ahead of the read (`premerged`, #625) has 

1211 # claimed the particles behind it already, so its chain 

1212 # is a decision though `j == k + 1` ('Freiherr von der'). 

1213 # (Tag test first: it is a set lookup and almost no name 

1214 # has an ambiguous particle, while is_leading_title is a 

1215 # call per piece.) 

1216 if ((j > k + 1 or pieces[k][0] in premerged) 

1217 and "vocab:particle-ambiguous" 

1218 in tokens[pieces[k][0]].tags): 

1219 while titled < k and is_leading_title( 

1220 pieces[titled], ptags[titled], tokens): 

1221 titled += 1 

1222 if titled == k: 

1223 i = pieces[k][0] 

1224 ambiguities.append(PendingAmbiguity( 

1225 AmbiguityKind.PARTICLE_OR_GIVEN, 

1226 f"{tokens[i].text!r} was chained onto the " 

1227 f"following name piece; it is also a given " 

1228 f"name in other names", 

1229 (i,), 

1230 # where it would stand unchained is 

1231 # assign's to name 

1232 alternative=name_reading(tokens[i], 

1233 Deferred.UNCHAINED))) 

1234 # rules/S4: "A BARE ambiguous acronym is consumed 

1235 # only when the name has words to spare — as the second 

1236 # of two words it stays the family name — and at the 

1237 # slots that report, either reading carries the 

1238 # ambiguity flag" 

1239 # 

1240 # The chain's half of SUFFIX_OR_NAME: once the chain 

1241 # takes the acronym, the lone piece assign reports on 

1242 # is gone, so the fork reports here 

1243 # (mechanisms.md#AMBIGUITY-AT-THE-DECISION-SITE). It is 

1244 # the DECLINED reading: the scan stops at 

1245 # `len(pieces) - tail`, before every piece the peel 

1246 # took, and a re-peel truncates the first pass's 

1247 # appends with the pieces. `j > k + 1` is its floor 

1248 # too, and `not is_prefix_piece(last, ...)` leaves a 

1249 # word in both vocabularies ending a PARTICLE run to 

1250 # P6's fork; it costs a frame, so it is tested last. A 

1251 # member S2's read picked is reported here and leaves 

1252 # the read's picks. decisions/S6 (#289), decisions/S2 

1253 # (#614). 

1254 last = pieces[j - 1] 

1255 # A numeral S2's read declined (rules/S10, #718) is not 

1256 # reported here: it stays one of the read's picks, and 

1257 # assign words it from the role it ends in ('Freiherr 

1258 # von Richthofen V', 'a family name'), the chain taking 

1259 # nothing the read had not already given up 

1260 if (j > k + 1 and len(last) == 1 

1261 and not tokens[last[0]].tags.isdisjoint( 

1262 _AMBIGUOUS_CREDENTIAL_TAGS) 

1263 and not is_prefix_piece(last, ptags[j - 1], 

1264 tokens)): 

1265 ambiguities.append(PendingAmbiguity( 

1266 AmbiguityKind.SUFFIX_OR_NAME, 

1267 f"{tokens[last[0]].text!r} is both a post-nominal " 

1268 f"and an ordinary name; the particle chain took " 

1269 f"it into the name rather than reading it as a " 

1270 f"post-nominal", 

1271 tuple(last), 

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

1273 took.add(tuple(last)) 

1274 merge_pieces(pieces, ptags, k, j, drop={"prefix"}) 

1275 k += 1 

1276 

1277 # Where the run was read above (#614) it is split off and 

1278 # `tail` is 0, so none of what follows runs, as it does not 

1279 # where no particle stands past the lead (#623); it is the path 

1280 # for the segments that read no run above -- a family comma's 

1281 # two name parts and a run with a name piece inside it (a part past the 

1282 # second comma makes no chain, #636) -- and the given part 

1283 # reaches it still 

1284 # ('Smith, Freiherr van Berg X.Y.Z.', the row that holds it). 

1285 # 

1286 # The peel was read over the pieces as they stand, and the 

1287 # chain's own merges can change what it counts: behind a word 

1288 # in both the title and particle vocabularies the scan above 

1289 # stops where assign's title peel does not (P4, #367), so the 

1290 # chain takes the name's first word, and the acronym the fork 

1291 # counted with three pieces meets assign with two -- 'Freiherr 

1292 # von Berg Ma' read given 'von Berg', family 'Ma' (1.4.0's 

1293 # reading; the reviews found it behind the claim that the 

1294 # merges leave the count alone). So the peel is asked again 

1295 # over the pieces the chain leaves, and where it no longer 

1296 # takes what the chain stopped before, the chain runs again 

1297 # without that stop: what assign will not peel is a name word, 

1298 # and the chain takes it. The numeral cannot flip (a chain 

1299 # group is never initial-shaped), so the second run is the 

1300 # acronym's alone, and rare; the snapshot is one copy per 

1301 # segment with a trailing run, linear like the rest. 

1302 if tail: 

1303 kept = ([list(q) for q in pieces], [set(t) for t in ptags], 

1304 len(ambiguities)) 

1305 chain(tail) 

1306 start = leading_titles(pieces, ptags, tokens) 

1307 left = len(pieces) - ( 

1308 trailing_run_start(start, pieces, ptags, tokens, one_case) 

1309 if given_slot 

1310 else trailing_start(start, pieces, ptags, tokens, 

1311 one_case=one_case)) 

1312 if left < tail: 

1313 pieces[:], ptags[:] = kept[0], kept[1] 

1314 del ambiguities[kept[2]:] 

1315 chain(left) 

1316 elif site is not ClauseSite.TAIL: 

1317 chain(0) 

1318 if read is not None and took: 

1319 # built, not `_replace`d: two frames a call 

1320 peel = read.peel 

1321 read = TailRead(read.tail, read.titles, read.run_titles, Peel( 

1322 peel.names, peel.numerals, 

1323 tuple([p for p in peel.picks if p not in took]), 

1324 peel.anchors, peel.run_titles, peel.absorbed)) 

1325 # rules/P5: "a recognized bound given-name word joins the 

1326 # word after it into one given name" (history: decisions/P5) 

1327 # -- bound given names: the first non-title piece joins the next 

1328 # ONCE (pairwise, v1 parity: 'Salem, Abdul Rahman Ahmed' keeps 

1329 # Ahmed a middle name). BoundJoin encodes v1's reserve_last. 

1330 # "the first non-title piece" by assign's count (#424): group's 

1331 # title test does not see H2's unlisted abbreviations, and 

1332 # 'Xyz. abdul John Smith' joined nothing where 'Dr. abdul John 

1333 # Smith' read given 'abdul John'. 

1334 fk = leading_titles(pieces, ptags, tokens) 

1335 if (bound_join is not BoundJoin.DISABLED 

1336 and fk + 1 < len(pieces) 

1337 and len(pieces[fk]) == 1 

1338 and "vocab:bound-given" in tokens[pieces[fk][0]].tags): 

1339 # P5 joins the bound word to "the word after it", and a 

1340 # marker is not a name word -- it is the announcement that 

1341 # another name follows. The only marker left by now is one 

1342 # the consumer declined (nothing after it, or nothing but 

1343 # a suffix), and the join must still not take it: 'Berg, 

1344 # abdul nee PhD' read given 'abdul nee', and 'Berg, abdul 

1345 # nee' clears the LENIENT reserve the same way. Measured 

1346 # rather than assumed -- dropping this undid #411 on 

1347 # exactly that row. Nor is a suffix piece (#421) -- 

1348 # rules/P5: "nor a word of the unambiguous suffix 

1349 # vocabulary (S2), wherever position will then place it" 

1350 # -- and declining it is also what keeps merge_pieces' tag 

1351 # union from making the joined piece a suffix piece. 

1352 # rules/P5: "nor a title word a trailing rule reads as a 

1353 # title" -- after a family comma one H5's trailing run takes 

1354 # from the given part, read as the comma-less name reads it, 

1355 # plus the bare salutations H8 adds after a family comma 

1356 # (`salutations=True`), 

1357 # which the LENIENT join reads no peel for and so asks here, 

1358 # only where a title word or one written short follows ('Berg, abdul Prof.', 

1359 # #637; where the given part's own walk then reads the words 

1360 # behind it otherwise, the title stays a name word, which 

1361 # rules/P5 accepts); in a part past the second 

1362 # comma any title word, which C2 reads as a title ('John 

1363 # Smith, Jr., abdul Dr. Smith', #640). A title word standing 

1364 # in the name is a name word and joins ('abdul Sir Smith 

1365 # Berg', 'Berg, abdul Prof. Ali'). 

1366 # A credential split across a space (rules/S12) is declined 

1367 # as its one-word spelling is: merged in the given part 

1368 # before this join, so the particle chain stops in front of 

1369 # it too ('Rahman, Abdul MB ChB', 'Smith, John van Pharm D') 

1370 # After a family comma a member its capitals settle as the 

1371 # credential (rules/S6) is declined as a suffix word is: the 

1372 # join would read it as the given name's second word, where 

1373 # the given part's trailing slot or the run S8 starts takes 

1374 # it ('Smith, Abdul MA'); with no comma nothing would, and 

1375 # the join stands ('Abdul MA Smith') 

1376 nxt, nxt_tags = pieces[fk + 1], ptags[fk + 1] 

1377 if (_is_maiden_marker_piece(nxt, tokens) 

1378 or is_suffix_piece(nxt, nxt_tags, tokens) 

1379 or "split" in nxt_tags 

1380 or (bound_join is BoundJoin.LENIENT and len(nxt) == 1 

1381 and AMBIGUOUS_ACRONYM_TAG in tokens[nxt[0]].tags 

1382 and settled_by_case(tokens[nxt[0]], one_case)) 

1383 or ((site is ClauseSite.TAIL 

1384 or bound_join is BoundJoin.LENIENT) 

1385 and (is_title_piece(nxt, nxt_tags, tokens) 

1386 # after a family comma only: a title 

1387 # written short that may be a given name, 

1388 # which H5's run takes with its period 

1389 # (#999), the tail_reading test deciding; 

1390 # past a second comma C2 reads only a 

1391 # listed title 

1392 or (site is not ClauseSite.TAIL 

1393 and len(nxt) == 1 

1394 and TRAILING_TITLE_TAG 

1395 in tokens[nxt[0]].tags)) 

1396 and (site is ClauseSite.TAIL 

1397 or fk + 1 in tail_reading( 

1398 peel_walk(fk, ptags), pieces, ptags, 

1399 tokens, one_case, 

1400 salutations=True)[1]))): 

1401 pass 

1402 elif bound_join is BoundJoin.LENIENT: 

1403 # post-comma the family is fixed and the pair is the 

1404 # given whatever follows, so no peel is read 

1405 # (decisions/P5, #423), save the title test above 

1406 merge_pieces(pieces, ptags, fk, fk + 2, drop={"title"}) 

1407 else: 

1408 # rules/P5: "the trailing suffix run (S2) and the 

1409 # trailing title run (H5), each read over what the other 

1410 # leaves until neither takes anything more, are read 

1411 # once, before the join is tried, and the name words they 

1412 # leave are the words to spare" (history: decisions/P5). 

1413 # That reading is the one above, after P3's joins and 

1414 # before the chain (#614): the run is 

1415 # split off, every piece left is a name piece, and the 

1416 # join leaves one fewer. Nor can the join move a suffix 

1417 # -- rules/P5: "a word the run left as a name word 

1418 # stays one, whatever the join makes of the word in 

1419 # front of it" -- since nothing in group reads the run 

1420 # again after it. 

1421 # 

1422 # Where the run was not read above (a run with a name 

1423 # piece inside it, _pieces.read_trailing_run), the 

1424 # reserve still reads both views, as it did everywhere 

1425 # before #614: the join tried on the pieces as it would 

1426 # leave them, the reading assign runs over each (the 

1427 # ONE function, _pieces.tail_reading), the suffixes 

1428 # compared as the peeled pieces themselves -- 'abdul 

1429 # Smith V' peels the V unjoined and nothing joined (no 

1430 # word to spare there, rules/S10), 'abdul Smith Ma' 

1431 # peels the acronym unjoined and keeps it joined. 

1432 if read is not None: 

1433 same_suffixes = True 

1434 chained = False 

1435 names_after = len(peel_walk(fk, ptags)) - 1 

1436 else: 

1437 rest, chain_took, before = tail_reading( 

1438 peel_walk(fk, ptags), pieces, ptags, tokens, 

1439 one_case) 

1440 view, view_tags = list(pieces), list(ptags) 

1441 view[fk:fk + 2] = [pieces[fk] + pieces[fk + 1]] 

1442 view_tags[fk:fk + 2] = [joined_tags(ptags, fk, fk + 2, 

1443 drop={"title"})] 

1444 view_rest, _, after = tail_reading( 

1445 peel_walk(fk, view_tags), view, view_tags, tokens, 

1446 one_case) 

1447 same_suffixes = ( 

1448 [tuple(view[j]) for j in view_rest[after.names:]] 

1449 == [tuple(pieces[j]) for j in rest[before.names:]]) 

1450 chained = fk + 1 in chain_took 

1451 names_after = after.names 

1452 # rules/P5: "a trailing roman numeral the run takes 

1453 # (S10), or a bare acronym it takes, or a trailing title 

1454 # word the run takes, is no word to spare" -- and the join joins 

1455 # two NAME words, so a piece the chain takes is no more 

1456 # joinable than a marker or a suffix piece is: 'Sir 

1457 # abdul Prof.' reads title 'Sir Prof.', given 'abdul'. 

1458 # Read off the UNJOINED view, the one that still has 

1459 # the title as a piece of its own -- the join would 

1460 # swallow it, and a swallowed title is a title the 

1461 # joined view can no longer see. The suffix comparison 

1462 # alone said this while the counts were subtractions, 

1463 # by leaving the chained piece in the tail it compared; 

1464 # under the shared reading the chain takes it out of 

1465 # both views, so the rule is asked as the rule. 

1466 # A given-name title ahead of the bound word asserts 

1467 # that a given name follows -- the assertion H1 reads 

1468 # when it keeps "Sir John" a given name -- so behind 

1469 # one there is no family to spare (#369). Asked of the 

1470 # title run through the ONE predicate post_rules asks 

1471 # for H1, so the two rules cannot disagree about what 

1472 # one run asserts; what it reads is the whole run's key 

1473 # or that key's LAST word (#489). And it is the same 

1474 # RUN on both sides: `range(fk)` is the pieces AHEAD of 

1475 # the bound word, and H1 asks its own question of the 

1476 # leading run too (_post_rules._addressing_run). They 

1477 # did disagree for one commit -- H1 keyed every TITLE 

1478 # token, so the trailing title in 'Sir abdul rahman 

1479 # Prof.' joined this run and flipped the join's own 

1480 # premise, handing the licensed pair to the family. 

1481 # Reading the leading run at both sites is what makes 

1482 # that unreachable rather than merely unlikely: a 

1483 # trailing title is behind the word, and neither site 

1484 # can see it. What H2's unlisted abbreviations do 

1485 # inside such a run is the predicate's own business, 

1486 # and its docstring is where they are worked through. 

1487 # The licence lifts the reserve for two name 

1488 # WORDS: the piece the join would take must be one word 

1489 # -- a particle chain is the family name P2 built ('Sir 

1490 # abdul van der Berg' keeps family 'van der Berg'). 

1491 licensed = (fk > 0 and len(pieces[fk + 1]) == 1 

1492 and _run_addresses_by_given( 

1493 (tokens[i].text 

1494 for k in range(fk) 

1495 for i in pieces[k]), 

1496 given_name_titles)) 

1497 reserve = BoundJoin.LENIENT if licensed else BoundJoin.STRICT 

1498 if (not chained and same_suffixes 

1499 and names_after >= reserve): 

1500 # the pair is a given name whatever tag the word 

1501 # carried (rules/P5); joined_tags says why the 

1502 # title tag is dropped. Pinned in test_group.py. 

1503 merge_pieces(pieces, ptags, fk, fk + 2, drop={"title"}) 

1504 # `phrase_heads` is passed only for a part holding a tagged word 

1505 if (phrase_heads is not None and bound_join is not BoundJoin.DISABLED 

1506 and site is not ClauseSite.TAIL): 

1507 _join_bound_phrases(pieces, ptags, tokens, phrase_heads) 

1508 if reads is not None: 

1509 reads.append(read) 

1510 pieces.extend(tail_pieces) 

1511 ptags.extend(tail_ptags) 

1512 return pieces, ptags, taken 

1513 

1514 

1515def group(state: ParseState) -> ParseState: 

1516 # rules/C1's decision, read once now that classify has tagged 

1517 # the words and before any join below can reach the part after the 

1518 # comma (#613); a postnominal part comes back bound, its segment 

1519 # emptied (a name with no comma asks nothing) 

1520 if state.structure is Structure.FAMILY_COMMA: 

1521 state = _comma.decide(state) 

1522 tokens = list(state.tokens) 

1523 dropped = list(state.dropped) 

1524 ambiguities = list(state.ambiguities) 

1525 all_pieces: list[tuple[tuple[int, ...], ...]] = [] 

1526 all_ptags: list[tuple[frozenset[str], ...]] = [] 

1527 all_reads: list[TailRead | None] = [] 

1528 # v1 parity: additional_parts_count=1 applies only to FAMILY_COMMA 

1529 # parts; the SUFFIX_COMMA pre-comma segment gets 0. 

1530 additional = 1 if state.structure is Structure.FAMILY_COMMA else 0 

1531 # v1 expand_suffix_delimiter parity (#206): tail segments (wholly 

1532 # consumed as suffixes by assign) are cut at their delimiter-core 

1533 # tokens, which land in `dropped` as the maiden marker does; the 

1534 # built-in cores, which no v1 release read, cut them too (#889) 

1535 cores = delimiter_cores(state.policy.extra_suffix_delimiters) 

1536 tail_start = {Structure.SUFFIX_COMMA: 1, 

1537 Structure.FAMILY_COMMA: 2}.get(state.structure) 

1538 family_comma = state.structure is Structure.FAMILY_COMMA 

1539 for seg_idx, seg in enumerate(state.segments): 

1540 if family_comma: 

1541 bound_join = (BoundJoin.LENIENT if seg_idx == 1 

1542 else BoundJoin.DISABLED) 

1543 else: 

1544 bound_join = BoundJoin.STRICT 

1545 # Suppressed after a family comma for the same reason _assign 

1546 # suppresses it there: the family name is already fixed, so 

1547 # there is no fork left to report. A tail segment needs no 

1548 # suppression: group makes no particle chain there (#636) and 

1549 # takes no maiden clause (rules/M2, #601), so nothing in group 

1550 # reports in one. rules/C2: "a part the parse consumes wholly 

1551 # as suffixes raises no report that a word of it was read as a 

1552 # name" 

1553 tail = tail_start is not None and seg_idx >= tail_start 

1554 # #601: where a maiden clause would stand, off the three facts 

1555 # already in hand here -- which decides whether a marker counts 

1556 # at all and which rule reads the end of the part. 

1557 if tail: 

1558 site = ClauseSite.TAIL 

1559 elif family_comma: 

1560 site = (ClauseSite.GIVEN_SLOT if seg_idx == 1 

1561 else ClauseSite.FAMILY_PART) 

1562 else: 

1563 site = ClauseSite.TRAILING 

1564 # rules/C10: "a suffix delimiter parts the suffix words on each 

1565 # side of it as a comma would" (#549): the segment is 

1566 # grouped as the parts between its cores, each read exactly as 

1567 # the comma twin's own segment would be, and the cores are 

1568 # dropped (v1 expand_suffix_delimiter parity, #206), where 

1569 # post_rules' entry pass reads them back as entry boundaries. 

1570 # No join or link search reaches across a core, because none 

1571 # of them ever sees one, and a maiden marker in a tail part is 

1572 # an ordinary word (#601). A one-token segment that 

1573 # is its core keeps it (v1 expand() splits within a part, never 

1574 # erases a lone part); a segment of several cores and nothing 

1575 # else drops them all, as the #206 drop did. 

1576 # Accumulated in a list and frozen once per part: a tuple 

1577 # extended per token is quadratic in the part's length, C-level 

1578 # work no frame guard sees (#553's class; review of #549). 

1579 # Measured 2026-10-03, py3.11, 'Smith, John, ' + 'PhD ' * n + 

1580 # '- MD' under a ' - ' delimiter: 4.7x for n 4,000 -> 16,000 as 

1581 # written, the default policy's own 4.5x, where the tuple read 

1582 # 8.0x. No clock guard: _PREFIXED_SHAPES parses with the default 

1583 # policy and _POLICY_SHAPES repeats a unit with no prefix, and a 

1584 # tail needs both, so a row would mean a table of its own. 

1585 parts: list[tuple[int, ...]] = [seg] 

1586 if tail and cores and len(seg) > 1: 

1587 parts = [] 

1588 current: list[int] = [] 

1589 for i in seg: 

1590 if tokens[i].text in cores: 

1591 dropped.append(i) 

1592 if current: 

1593 parts.append(tuple(current)) 

1594 current = [] 

1595 else: 

1596 current.append(i) 

1597 if current: 

1598 parts.append(tuple(current)) 

1599 pieces = [] 

1600 ptags = [] 

1601 seg_reads: list[TailRead | None] = [] 

1602 for part in parts: 

1603 # a plain loop, no frame: a name with no listed pair pays 

1604 # nothing (rules/P12) 

1605 heads: set[int] | None = None 

1606 for i in part: 

1607 if BOUND_PHRASE_TAG in tokens[i].tags: 

1608 heads = set() 

1609 break 

1610 part_pieces, part_ptags, taken = _group_segment( 

1611 part, additional, tokens, bound_join, 

1612 None if family_comma else ambiguities, 

1613 state.lexicon._sets.given_name_titles, 

1614 opens_the_name=(seg_idx == 0 and not family_comma), 

1615 one_case=state.one_case, 

1616 site=site, reads=seg_reads, phrase_heads=heads, 

1617 given_first=state.policy.name_order[0] is Role.GIVEN) 

1618 if heads is not None: 

1619 # the tag stays only on a pair P12 joined, which O6 

1620 # counts as one word; P5's pair at the start, and a 

1621 # pair a chain or a comma kept apart, count as two 

1622 for i in part: 

1623 if BOUND_PHRASE_TAG in tokens[i].tags and i not in heads: 

1624 tokens[i] = copy_with( 

1625 tokens[i], tags=tokens[i].tags - {BOUND_PHRASE_TAG}) 

1626 pieces.extend(part_pieces) 

1627 ptags.extend(part_ptags) 

1628 # the marker is dropped and the maiden name's tokens become 

1629 # MAIDEN (#274); which pieces those are was settled in 

1630 # _group_segment, before the joins 

1631 if taken is not None: 

1632 marker_piece, removed_pieces = taken 

1633 dropped.extend(marker_piece) 

1634 # the maiden name, and the trailing run the take 

1635 # consumed with the roles the clause-free name's 

1636 # reading gave it (#601) 

1637 for piece, role in removed_pieces: 

1638 for i in piece: 

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

1640 # Group used to decide the suffix ENTRY, marking a continuation 

1641 # token "joined" between pieces off segment SHAPE -- `tail` by 

1642 # index, ORed since #429 with segment_suffix_reading's 

1643 # per-piece content verdict, gated per piece and sticky across 

1644 # an interleaved title. post_rules derives that from the 

1645 # commas the writer typed instead (#436/#437, rules/R1), 

1646 # which is what the input answers directly. The stickiness 

1647 # survives by construction rather than by code: 

1648 # 'Smith, MD Dr. PhD' has no comma between MD and PhD and a 

1649 # title between them renders elsewhere, so they join, while 

1650 # 'Smith Jr., Mr. Jr.' has the writer's own comma and they do 

1651 # not. 

1652 # continuation tokens of a suffix-merged piece (the ph-d merge) 

1653 # carry the stable "joined" tag: the suffix string view joins 

1654 # SUFFIX tokens with ", ", and the tag lets it heal the split 

1655 for piece, piece_tags_ in zip(pieces, ptags): 

1656 if "suffix" in piece_tags_ and len(piece) > 1: 

1657 for i in piece[1:]: 

1658 tokens[i] = copy_with( 

1659 tokens[i], tags=tokens[i].tags | {"joined"}) 

1660 all_pieces.append(tuple(tuple(p) for p in pieces)) 

1661 all_ptags.append(tuple(frozenset(t) for t in ptags)) 

1662 all_reads.append(seg_reads[0] if len(seg_reads) == 1 else None) 

1663 # rules/M1: "a leading recognized marker is dropped where the 

1664 # clause holds a word past it" and "a clause of nothing but its 

1665 # marker keeps its words" — Then's marker drop and the Then bullet 

1666 # on a marker-only clause, for a marker inside EXTRACTED maiden 

1667 # content (decisions/M1, #329). Classify tags 

1668 # such a marker like any other token -- what the #274 rule above 

1669 # lacks is not the TAG but the token: extract claims a delimited 

1670 # clause and tokenize gives its tokens Role.MAIDEN up front, so 

1671 # segment (main stream = role is None) leaves them out of every 

1672 # segment, they never enter `pieces`, and a rule that walks pieces 

1673 # cannot reach them. 

1674 # 

1675 # Scoped to the CLAUSE, via state.extracted (one role + inner span 

1676 # per delimited region), rather than to a maiden token's 

1677 # neighbours. Both reasons are load-bearing: 

1678 # * Role.MAIDEN is not proof of extraction -- the #274 rule above 

1679 # sets it too, on the bare form, earlier in this same function. 

1680 # A neighbour test would fire there and eat the 'Nee' out of 

1681 # "Jane Smith nee Nee Jones". Keying on extracted spans puts 

1682 # the bare path out of reach by construction. 

1683 # * Separate clauses are separate content. In "(Nee) (Jones)" the 

1684 # two land as one contiguous run of maiden tokens, so only the 

1685 # clause bound keeps the lone "(Nee)" intact. 

1686 # Drop the clause's LEADING MARKER only when the clause holds a 

1687 # word past it: `Nee` is a real surname (Irish Ni/Nee, and a 

1688 # Chinese romanization), so a one-token "(Nee)" is a maiden name, 

1689 # not a marker. The marker and no more, whatever the clause holds 

1690 # past it: cases.py's maiden_marker_delimited_three_token_clause is 

1691 # the row that bounds this in both directions, every other 

1692 # delimited row having a two-token clause where the two readings 

1693 # agree. The marker is one token for a word entry and the whole run 

1694 # for a phrase one ('z domu'), which is why the loop below counts 

1695 # continuation tags rather than assuming one. 

1696 # Spans index the original string by the anti-#100 

1697 # invariant, and script_segment only ever splits a token into 

1698 # sub-slices, so containment stays exact. 

1699 # 

1700 # Bisect rather than scan the token list per clause: that is 

1701 # quadratic in the number of delimited pairs, and "(a) " * 3200 -- 

1702 # 4x test_benchmark's base, NOT a doubling -- measured a 14.1x cost 

1703 # against the 4.1x the same shape holds under a policy with no 

1704 # maiden_delimiters. The control is what says which unit a ratio is 

1705 # in: linear is ~4 for 4x the input and ~2 for a doubling, so a 4.1x 

1706 # control cannot be per doubling. Re-measured 2026-08-03 at 11.2x 

1707 # against 4.2x (3.2x against 2.1x per doubling) -- the separation 

1708 # replicates, the exact ratio moves with the runner. Same idiom, 

1709 # and the same reason, as _extract._overlaps and _tokenize's origin 

1710 # resolution. test_benchmark's maiden_pairs shape is the guard. 

1711 if any(role is Role.MAIDEN for role, _ in state.extracted): 

1712 starts = [t.span.start for t in tokens] 

1713 for role, clause in state.extracted: 

1714 if role is not Role.MAIDEN: 

1715 continue 

1716 # first token starting at or after the clause opens; tokens 

1717 # are span-sorted and group never reorders or resizes them 

1718 first = bisect.bisect_left(starts, clause.start) 

1719 if (first >= len(tokens) 

1720 or "vocab:maiden-marker" not in tokens[first].tags): 

1721 continue 

1722 # How many tokens the marker is: one, or the whole run of a 

1723 # phrase entry, as classify recorded it. Same walk the 

1724 # piece test above makes, in token-index space. 

1725 run = marker_run_length( 

1726 tokens[k].tags for k in range(first + 1, len(tokens))) 

1727 # Testing the end of the token AFTER the run proves the run 

1728 # AND that token are all inside: tokens do not overlap and 

1729 # are index-ordered, so every earlier one ends no later, 

1730 # and bisect already put first.start at or after 

1731 # clause.start. That is also the "more than the marker" 

1732 # test, since the tokens inside a clause are contiguous in 

1733 # index order -- a clause holding nothing but its marker 

1734 # keeps its words, exactly as a one-token "(Nee)" does. 

1735 # rules/M3: "a marker followed by a comma is no marker" 

1736 # -- asked of the same word extract's M3 test asks it of 

1737 if (first + run < len(tokens) 

1738 and tokens[first + run].span.end <= clause.end 

1739 and not comma_follows_marker(tokens[first + run].text)): 

1740 dropped.extend(range(first, first + run)) 

1741 return copy_with( 

1742 state, tokens=tuple(tokens), pieces=tuple(all_pieces), 

1743 piece_tags=tuple(all_ptags), dropped=tuple(dropped), 

1744 tail_reads=tuple(all_reads), ambiguities=tuple(ambiguities))