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