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

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

384 statements  

1"""Not a stage: rules/C1's decision, made by group at its head. 

2 

3The comma's structure is decided ONCE, after classify has tagged every 

4word and before group joins any of them (#613): the part after the 

5first comma is read by `_pieces.segment_suffix_reading`, the one 

6reading a part that holds no name word gets, with the words C4's count 

7licenses as credentials behind a whole name. Where it reads as the 

8postnominal part its roles are set here, so no join in group and no 

9walk in assign can reach them, and the comma is a suffix comma when 

10the part before it is a whole name; otherwise the family comma 

11`segment` handed over stands, and assign reads the listing form. 

12 

13Deciding in `segment`, before the tags existed, meant predicting what 

14later stages would do -- group's particle chain (#562's pair), assign's 

15reading of a run the capitals settle -- and assign re-deciding #296's 

16positional read with a second count; reading once and binding is 

17mechanisms.md#READ-WITHOUT-THEN-BIND. 

18 

19Reads: Lexicon suffix, title, particle and ambiguous-class vocabulary 

20through the tags classify wrote and through _vocab.name_word_count and 

21_vocab.is_wholly_suffix; every Lexicon wordlist through 

22_vocab.caps_shape_candidate (to know an all-caps word is unlisted) and 

23_vocab.claimed_as_non_name (the name's contrast); Lexicon 

24.maiden_markers through _pieces.own_words, for the name's contrast; 

25Policy.lenient_comma_suffixes, Policy.extra_suffix_delimiters, 

26Policy.unlisted_caps_suffixes and Policy.unlisted_dotted_suffixes. 

27""" 

28from __future__ import annotations 

29 

30import unicodedata 

31from collections.abc import Sequence, Set 

32 

33from nameparser._lexicon import Lexicon, _normalize 

34from nameparser._pipeline._pieces import ( 

35 split_credential_report, 

36 has_name_content, is_suffix_piece, is_title_piece, join_connectives, 

37 listed_lean, own_words, segment_suffix_reading, settled_by_case, 

38) 

39from nameparser._pipeline._state import ( 

40 AMBIGUOUS_ACRONYM_TAG, AMBIGUOUS_WORD_TAG, GENERATION_WORD_TAG, 

41 SHAPE_ACRONYM_TAG, 

42 SPLIT_CREDENTIAL_TAG, SPLIT_MEMBER_TAG, ParseState, PendingAmbiguity, 

43 PendingReading, Structure, WorkToken, copy_with, 

44) 

45from nameparser._pipeline._vocab import ( 

46 ROMAN, caps_shape_candidate, claimed_as_non_name, 

47 composed_len, delimiter_cores, ends_in_a_generation, glued_cores, 

48 glued_stem, glues, is_caps_word, 

49 is_letter_run, is_paired_initials, is_single_letter_numeral, 

50 is_wholly_suffix, _joined_chunks, _listed_whole, 

51 name_reading, name_word_count, reads_as_generation, suffix_reading, 

52 surname_unit_facts, unit_ends, written_as_a_name, 

53) 

54from nameparser._policy import CapsSuffixes 

55from nameparser._types import AmbiguityKind, Role 

56 

57 

58def _pieces(seg: Sequence[int], tokens: Sequence[WorkToken], 

59 splits: bool = True, 

60 ) -> tuple[list[list[int]], list[set[str]]]: 

61 """One piece per token but a split credential (`split_pair`, read 

62 off the tag classify wrote on its first word), which group's first 

63 merge builds (group alone declines a pair at the head of the 

64 string, rules/S2) -- the part as it stands before any join: the 

65 Ph./D. pair tagged "suffix" where the lexicon lists `phd` as 

66 unambiguous suffix vocabulary, any other "split", a member of the 

67 ambiguous class for rules/C4's count, unless `splits` is False. 

68 Every piece a fresh list, as `join_connectives` requires: it 

69 extends a joined piece's first list in place.""" 

70 pieces: list[list[int]] = [] 

71 ptags: list[set[str]] = [] 

72 k = 0 

73 while k < len(seg): 

74 a = seg[k] 

75 tags = tokens[a].tags 

76 if k + 1 < len(seg) and (SPLIT_CREDENTIAL_TAG in tags 

77 or splits and SPLIT_MEMBER_TAG in tags): 

78 pieces.append([a, seg[k + 1]]) 

79 ptags.append({"suffix" if SPLIT_CREDENTIAL_TAG in tags 

80 else "split"}) 

81 k += 2 

82 else: 

83 pieces.append([a]) 

84 ptags.append(set()) 

85 k += 1 

86 return pieces, ptags 

87 

88 

89def _reading_pieces(part: Sequence[int], tokens: Sequence[WorkToken], 

90 splits: bool = True, 

91 ) -> tuple[list[list[int]], list[set[str]]]: 

92 """The part as the reading takes it: `_pieces`, then the one join it 

93 can hold.""" 

94 pieces, ptags = _pieces(part, tokens, splits) 

95 # The one join a postnominal part can hold, made here so the reading 

96 # sees it: rules/P3's connective joining its neighbours where the 

97 # neighbour it takes its kind from is a title, which makes the 

98 # joined part a title ('Mr. and Mrs.', 'Secretary of State', 'Dr. 

99 # and'), a run of connectives merging first as it does in the name 

100 # ('Minister of the Interior'). A LONE single-letter connective is 

101 # left as it stands, P3 or P8 reading it as a name word or an initial in 

102 # short names; one beside another connective is a member of their 

103 # run and joins with it ('Mr. y and Mrs.'). Any other join is not 

104 # made here: where no credential opens the part, a name word in it 

105 # makes it the given part; where one does, the run #603 opens takes 

106 # the connective as a word and reports it ('John Smith, PhD and 

107 # Dr.'). Group's own joins, the one loop since #617: its copy here 

108 # merged no run and joined 'Minister of the' alone. 

109 # 

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

111 # joins only where a name word stands on each side of it" -- and in 

112 # a part read for credentials none does, so every such connective 

113 # is frozen here, where group freezes only the ones it finds without 

114 # a name word beside them ('Rovira, Dr. i i' keeps suffix 'i i' 

115 # rather than run-merging the pair into the title, #617); a 

116 # neighbour's join may still take one in ('Smith, Dr. and i' reads 

117 # title 'Dr. and i'), as `join_connectives` says. 

118 # 

119 # The same walk says whether there is anything to join, and a part 

120 # with no connective left free -- nearly every part -- skips the 

121 # shared loop, whose predicate calls cost two frames per word 

122 # ('Smith, John' paid two; #617's review). 

123 frozen: set[int] = set() 

124 free = False 

125 for i in part: 

126 tags = tokens[i].tags 

127 if "conjunction" in tags: 

128 if "vocab:suffix" in tags: 

129 frozen.add(i) 

130 else: 

131 free = True 

132 if free: 

133 join_connectives(pieces, ptags, tokens, letter_stays=True, 

134 titles_only=True, frozen=frozen) 

135 return pieces, ptags 

136 

137 

138def _unclaimed_name_word(text: str, lexicon: Lexicon) -> bool: 

139 """A word written as a name and claimed by no wordlist as anything 

140 else -- #564's contrast word and rules/C12's veto, one question. 

141 The caller tests for a period first, in C: a word with one is an 

142 abbreviation or an initial, not a name word.""" 

143 return (written_as_a_name(text) 

144 and not claimed_as_non_name(_normalize(text), lexicon)) 

145 

146 

147def _name_contrast(state: ParseState, seg0: Sequence[int], 

148 clause_at: int | None = None) -> bool: 

149 """#564 (Derek): the contrast the caps shape needs is the NAME's, 

150 and the name carries it only if one of its own words before the 

151 comma (no maiden clause, no delimited content, as `own_words` 

152 defines them) is written the way a name is written in mixed case 

153 -- a capital in it and its last letter lowercase 

154 (`written_as_a_name`) -- and is not claimed as a title, particle, 

155 connective, credential or generation (`claimed_as_non_name`); a 

156 word with a period is an abbreviation or an initial, not one. A 

157 lone capital ('de GAULLE C'), a lowercase-only word and a surname 

158 written in capitals with anything glued in front ("d'ESTAING", 

159 'FitzGERALD', 'McDONALD') all fail it, so such a record keeps its 

160 given name beside its lowercase particles, clauses and titles and 

161 beside a mixed-case credential ('LLOYD WEBBER, ANDREW PhD'). A part 

162 with no lowercase at all is settled in one C-level comparison. 

163 `clause_at` is `own_words`' clause index where the caller already 

164 holds it.""" 

165 before = "".join([state.tokens[i].text for i in seg0]) 

166 if before == before.upper(): 

167 return False 

168 if clause_at is None: 

169 _, clause_at = own_words(state.tokens, state.comma_offsets, 

170 state.lexicon._sets.maiden_markers) 

171 lex = state.lexicon 

172 return any( 

173 i < clause_at and (tok := state.tokens[i]).role is None 

174 and "." not in tok.text and _unclaimed_name_word(tok.text, lex) 

175 for i in seg0) 

176 

177 

178def _whole_name(seg0: Sequence[int], tokens: Sequence[WorkToken], 

179 one_case: bool | None) -> bool: 

180 """rules/C3's count before the comma for an unambiguous 

181 credential, read off classify's tags (the ambiguous class's count 

182 of NAME words, which leaves titles out, is 

183 `_vocab.name_word_count`'s, asked in `decide`): two or more units (a particle run and 

184 the name word it attaches to one unit, a connective join not, #575) 

185 holding a word that is not a suffix word -- a title counts, being a 

186 word to the count an unambiguous credential takes, and so does a 

187 suffix word opening the part with a name word behind it. A numeral 

188 ending the part, or standing before the suffix words that end it, 

189 that the trailing fork reads as the generation is no name word, as 

190 a listed one is not (`one_case` is 

191 ParseState.one_case, for that fork).""" 

192 # rules/C3: "a particle run and the one name word it attaches to are 

193 # one word" 

194 # the part before a family comma merges no member pair (rules/S12) 

195 pieces, ptags = _pieces(seg0, tokens, False) 

196 named: set[int] = set() 

197 opener: list[int] = [] 

198 name_behind = False 

199 for k, (piece, tags) in enumerate(zip(pieces, ptags)): 

200 if not is_suffix_piece(piece, tags, tokens): 

201 named.update(piece) 

202 # a title behind it is a word to this count but no name 

203 # word, asked only once a suffix word has opened the part 

204 if opener and not is_title_piece(piece, tags, tokens): 

205 name_behind = True 

206 elif k == 0 and SPLIT_MEMBER_TAG not in tokens[piece[0]].tags: 

207 opener = piece 

208 # rules/C3: "A suffix word opening the part is a name word to the 

209 # count where a name word follows it" -- a split credential's 

210 # member is half a credential, not a name (rules/S12) 

211 if name_behind: 

212 named.update(opener) 

213 # rules/C3: "a numeral ending the part, or standing before the 

214 # listed suffix words it ends with, that the trailing fork reads as the 

215 # generation is no name word" ('Dumas VI, Jr.' and 'Dumas VI MD, 

216 # Ed' as their 'III' twins); decisions/C3 (#893). The suffix 

217 # words are the pieces the loop above left out of `named`. The 

218 # `ROMAN` match is a test the predicate makes itself 

219 # (`reads_as_generation`'s first), asked here at C level so a part 

220 # ending in no numeral pays no frame for it, as group's count does 

221 e = len(seg0) - 1 

222 while e > 0 and seg0[e] not in named: 

223 e -= 1 

224 if (e > 0 and ROMAN.match(tokens[seg0[e]].text.rstrip(".")) is not None 

225 and ends_in_a_generation( 

226 [tokens[seg0[e - 1]].text, tokens[seg0[e]].text], one_case)): 

227 named.discard(seg0[e]) 

228 if len(named) < 2: 

229 return False 

230 idx = list(seg0) 

231 names = 0 

232 start = 0 

233 for end in unit_ends([surname_unit_facts(tokens[i].tags, k == 0) 

234 for k, i in enumerate(idx)], chain=False): 

235 if not named.isdisjoint(idx[start:end]): 

236 names += 1 

237 if names > 1: 

238 return True 

239 start = end 

240 return False 

241 

242 

243def _members(seg: Sequence[int], tokens: Sequence[WorkToken]) -> set[int]: 

244 """The positions in `seg` of split credentials' members, for 

245 `name_word_count`'s opening word (rules/S12), on the one-letter 

246 numeral's rare path; `decide` scans for them inline.""" 

247 members: set[int] = set() 

248 for k, i in enumerate(seg): 

249 if SPLIT_MEMBER_TAG in tokens[i].tags: 

250 members.add(k) 

251 return members 

252 

253 

254def _behind_a_generation(state: ParseState, seg0: Sequence[int], 

255 p1: Sequence[list[int]], pt1: Sequence[set[str]], 

256 tokens: Sequence[WorkToken], contrast: bool | None, 

257 ) -> tuple[bool, ...] | None: 

258 """rules/C12's reading of the part after the comma, or None where 

259 the listing form stands: behind a whole name ending in a suffix 

260 word, or in a numeral the trailing fork reads as the generation, 

261 the part is the credential run, its titles (a word or a run a 

262 connective joins) reading as titles, unless a word of it is 

263 written as a name. The caller has tested the tag, or the numeral 

264 shape with `ROMAN`, as a cheap filter. The part's veto is asked 

265 first, being the cheap question and the one a listing with a given 

266 name answers ('Garcia Lopez Jr., Juan'). `contrast` is C6's answer 

267 where `decide` has already asked it, else None.""" 

268 # two name words and the generation at least: exact, since 

269 # `_whole_name` needs two units in front ('Smith VI, AD') 

270 if len(seg0) < 3: 

271 return None 

272 lex = state.lexicon 

273 reading: list[bool] = [] 

274 for p, pt in zip(p1, pt1): 

275 # a title reads as a title, a word or a run a connective joins 

276 # ('Mr. and Mrs.', a dual inside it included); a lone dual of 

277 # the title and the suffix vocabulary is the suffix here, the 

278 # slot being postnominal ('John Smith Jr., AD RA'): C13's two 

279 # predicates, asked in C13's order 

280 title = (not is_suffix_piece(p, pt, tokens) 

281 and is_title_piece(p, pt, tokens)) 

282 for i in p: 

283 tok = tokens[i] 

284 # rules/C12: "a word of it written as a name, or an 

285 # initial, keeps the listing form" -- unlisted, and a word 

286 # with a period is an abbreviation, as for the contrast; 

287 # paired initials are initials, as C5 keeps them ('J.A.') 

288 if "initial" in tok.tags or ( 

289 is_paired_initials(tok.text) if "." in tok.text 

290 else _unclaimed_name_word(tok.text, lex)): 

291 return None 

292 reading.append(not title) 

293 last = tokens[seg0[-1]].text 

294 # a numeral by shape alone (VI to X) is the generation the trailing 

295 # fork reads, as the listed ones are (#610's class: 'VI' beside 'V' 

296 # and 'III'), and not one it reads as an initial's neighbour 

297 # ('John J VI, AD'). C12's evidence is broader than a generation: 

298 # any suffix word ('Jr.', 'Esq.') counts, by its tag 

299 if ("vocab:suffix-word" not in tokens[seg0[-1]].tags 

300 and not reads_as_generation( 

301 last, tokens[seg0[-1]].tags, tokens[seg0[-2]].text, 

302 state.one_case)): 

303 return None 

304 # the generation set aside, the name's own words in front of it are 

305 # a whole name: 'Smith VI' is one word and a numeral, as 'Smith III' 

306 # is, and a maiden clause is no word of the name ('Jane nee Smith 

307 # Jr., AD' reads as 'Jane Jr., AD'; own_words, as for the contrast) 

308 _, clause_at = own_words(state.tokens, state.comma_offsets, 

309 lex._sets.maiden_markers) 

310 front = [i for i in seg0[:-1] 

311 if i < clause_at and tokens[i].role is None] 

312 if not _whole_name(front, tokens, state.one_case): 

313 return None 

314 # a one-letter numeral is the generation only where the name has a 

315 # word to spare for it, a title being none (rules/S10), and is no 

316 # evidence where the name reads it as a name word ('Dr. Smith V, 

317 # AD', whose title is a word to the count above but none here) 

318 if (len(last.rstrip(".")) == 1 and is_single_letter_numeral(last) 

319 and name_word_count([tokens[i].text for i in front], lex, 

320 state.policy, 

321 letters_one_case=state.one_case, 

322 members=_members(front, tokens)) < 2): 

323 return None 

324 if contrast is None: 

325 contrast = _name_contrast(state, seg0, clause_at) 

326 if not contrast: 

327 return None 

328 return tuple(reading) 

329 

330 

331def _given_part(token: WorkToken) -> PendingReading: 

332 """The name reading of a word this decision bound as a credential: 

333 the comma's other branch makes the part the given part, and with 

334 every other word of it read as the decision read it, the word is 

335 its given name (rules/C1's listing form).""" 

336 return name_reading(token, Role.GIVEN) 

337 

338 

339def _lone_word(state: ParseState, seg0: Sequence[int]) -> bool: 

340 """Whether the head before the comma is one word as written, for 

341 rules/C16's run of initials (#1238), where C4's count reads one name 

342 word: a word the count leaves out as a title is a word here 

343 ('Ashley Knight, M.S. BME'), and so is a particle unless written in 

344 lower case in a head that holds a capital, in a name of mixed case 

345 ('de Waart, J.P.', but 'Bin Zhang, M.D.', whose 'Bin' is as likely 

346 the given name, and 'du juan, M.D.', written all in lower case). A 

347 suffix word, or a numeral in capitals, of two or more letters ending 

348 the head behind a word is none ('Jansen Jr., J.P.', 'Jansen VI, 

349 J.P.', but 'Le Vi, M.D.' and 'Smith V, M.D.' are two words), and a 

350 title left alone is no surname ('Dr. VI, M.D.').""" 

351 tokens = state.tokens 

352 # as C3 counts it: one in front of a word is a word ('Dr.B. Akila., 

353 # M.D'), a single letter ends a whole name (C1) 

354 end = len(seg0) 

355 while end > 1: 

356 token = tokens[seg0[end - 1]] 

357 stem = token.text.rstrip(".") 

358 if len(stem) < 2 or not ("vocab:suffix" in token.tags 

359 or stem.isupper() 

360 and ROMAN.match(stem) is not None): 

361 break 

362 end -= 1 

363 word: WorkToken | None = None 

364 particle = False 

365 for i in seg0[:end]: 

366 token = tokens[i] 

367 if "particle" in token.tags and token.text.islower(): 

368 particle = True 

369 elif word is not None: 

370 return False 

371 else: 

372 # a capitalized particle is a word ('Le, J.P.' one, 'Bin 

373 # Zhang, M.D.' two), and so is a title word 

374 word = token 

375 if word is None: 

376 # an empty head reads as C15 has it, as 'Smith, X' less the 

377 # surname 

378 return not particle 

379 # a title left alone by the walk is no surname ('Dr. VI, M.D.'), a 

380 # title word standing alone is one ('Knight, J.P.'); a lower-case 

381 # word behind lower-case particles is a name all in lower case 

382 return ((end == len(seg0) or "vocab:title" not in word.tags) 

383 and not (particle and word.text.islower())) 

384 

385 

386def _company(state: ParseState, seg0: Sequence[int], p: Sequence[int], 

387 pt: Set[str], texts: dict[int, str], 

388 contrast: list[bool]) -> bool: 

389 """Whether one piece beside rules/C16's run is a credential that 

390 makes the part the credential run (#1238): a suffix word that is 

391 no generation and, if it is title vocabulary too, written in 

392 capitals; a word of the ambiguous class; or an unlisted capitals 

393 word C6 admits where the name carries its contrast (`contrast` 

394 holds that answer once asked). A split credential, or a piece of 

395 several words that is a suffix piece ('Ph. D.'), always is; a 

396 numeral by shape, like a listed generation, never is.""" 

397 tokens = state.tokens 

398 if "split" in pt: 

399 return True 

400 if len(p) != 1: 

401 return is_suffix_piece(p, pt, tokens) 

402 token = tokens[p[0]] 

403 text = texts[p[0]] 

404 if (GENERATION_WORD_TAG in token.tags 

405 or ROMAN.match(text.rstrip(".")) is not None): 

406 return False 

407 if is_suffix_piece(p, pt, tokens): 

408 return ("vocab:title" not in token.tags or text.isupper() 

409 or bool(state.one_case)) 

410 if AMBIGUOUS_ACRONYM_TAG in token.tags: 

411 return True 

412 if (state.one_case 

413 or not caps_shape_candidate(text, state.lexicon, state.policy, 

414 one_case=False)): 

415 return False 

416 if not contrast: 

417 contrast.append(_name_contrast(state, seg0)) 

418 return contrast[0] 

419 

420 

421def _credentials_beside(state: ParseState, seg0: Sequence[int], 

422 p1: Sequence[Sequence[int]], 

423 pt1: Sequence[Set[str]], k: int, 

424 texts: dict[int, str], 

425 contrast: list[bool]) -> bool: 

426 """Whether every piece of the part but the undotted pair at `k` is 

427 a credential the pair's own writing would not doubt (rules/C5, 

428 #1323), at least one of them behind it: a word of C16's company 

429 that is no title in front of the pair, no word gated by the count 

430 (S11: 'Junior', 'Filho', 'KC') and, where it is a member of the 

431 ambiguous class, one its capitals settle ('MA', not 'Ed'); and the 

432 name carries C6's contrast, asked here too, so a record in capitals 

433 stays the listing form under every caps setting. A particle is no 

434 credential ('Mc'), and a word S3 joins with a slash or a hyphen is 

435 asked chunk by chunk, as its words written apart would be ('MD/Jr.', 

436 'Ma/PhD'), a word the vocabulary lists whole being one word.""" 

437 tokens = state.tokens 

438 lexicon = state.lexicon 

439 for j, (q, qt) in enumerate(zip(p1, pt1)): 

440 if j == k: 

441 continue 

442 if j < k and is_title_piece(q, qt, tokens): 

443 return False 

444 if len(q) == 1 and "split" not in qt: 

445 token = tokens[q[0]] 

446 if "particle" in token.tags: 

447 return False 

448 # a word the count gates (S11: 'Junior', 'Filho', 'KC') is 

449 # refused here too: classify tags it a member of the class, 

450 # and `settled_by_case` never settles a gated word 

451 if (AMBIGUOUS_ACRONYM_TAG in token.tags 

452 and not settled_by_case(token, state.one_case)): 

453 return False 

454 text = texts[q[0]] 

455 if ("/" in text or "-" in text) and not _listed_whole( 

456 text, lexicon): 

457 for chunk in _joined_chunks(text, lexicon) or (): 

458 folded = _normalize(chunk) 

459 if (folded in lexicon._sets.generation_words 

460 or folded in lexicon._sets.suffix_words_ambiguous 

461 or ROMAN.match(chunk.rstrip(".")) is not None 

462 or folded.replace(".", "") 

463 in lexicon._sets.suffix_acronyms_ambiguous 

464 and (state.one_case or not chunk.isupper())): 

465 return False 

466 if not _company(state, seg0, q, qt, texts, contrast): 

467 return False 

468 if not contrast: 

469 contrast.append(_name_contrast(state, seg0)) 

470 return contrast[0] 

471 

472 

473def _run_read_as_initials(state: ParseState, i: int) -> ParseState: 

474 """The listing form, with the run of initials at `i` that made the 

475 part the given part (rules/C16, #1238) tagged an initial, as a run 

476 written apart is, so the given part's walk keeps it a name behind a 

477 title ('Jansen, Ir. J.P.'), and the decision reported here where it 

478 was taken: the other branch is the credential its letters spell.""" 

479 tokens = list(state.tokens) 

480 token = tokens[i] 

481 tokens[i] = copy_with(token, tags=token.tags | {"initial"}) 

482 return copy_with(state, tokens=tuple(tokens), ambiguities=( 

483 *state.ambiguities, 

484 PendingAmbiguity( 

485 AmbiguityKind.SUFFIX_OR_NAME, 

486 f"{token.text!r} after the comma also spells a credential; " 

487 f"read as ", (i,), field_tail="", 

488 alternative=suffix_reading(token)))) 

489 

490 

491def decide(state: ParseState, splits: bool | None = True) -> ParseState: 

492 """The structure of a comma form, and the postnominal part bound. 

493 Returns `state` unchanged where the listing form stands. 

494 

495 `splits` is for the retry below alone: None reads the part with each 

496 split credential merged and asks nothing further, False with none 

497 merged.""" 

498 if (state.structure is not Structure.FAMILY_COMMA 

499 or len(state.segments) < 2): 

500 return state 

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

502 # it is written as" -- the part read with each split credential 

503 # merged, and read again as written where the reading did not take 

504 # one as a credential, so a declined pair reads as it reads unmerged 

505 # (an 'MB' it hid opens the part again, rules/C13). A loop, and the 

506 # retry a call of this function only where a pair stands, so a part 

507 # holding none pays no frame for it. 

508 if splits: 

509 heads: list[int] = [] 

510 for i in state.segments[1]: 

511 if SPLIT_MEMBER_TAG in state.tokens[i].tags: 

512 heads.append(i) 

513 if heads: 

514 out = decide(state, None) 

515 if all(out.tokens[i].role is Role.SUFFIX for i in heads): 

516 return out 

517 return decide(state, False) 

518 seg0, seg1 = state.segments[0], state.segments[1] 

519 # Nothing after the comma: nothing to read. An EMPTY part BEFORE it 

520 # is read on, being no whole name (`_whole_name` answers no): 

521 # rules/C15: "the part after it reads as the given part after a 

522 # family comma reads" -- so ', PhD' binds its credential as 

523 # 'Smith, PhD' does; decisions/C15 (#892), decisions/C1 (#613) 

524 if not seg1: 

525 return state 

526 tokens = list(state.tokens) 

527 lex, pol = state.lexicon, state.policy 

528 # rules/C10: "a suffix delimiter parts the suffix words on each side 

529 # of it as a comma would" -- behind a whole name its cores are 

530 # no word of the reading, and leave as group drops them in a tail; 

531 # behind one name word the core is a word, v1 having applied the 

532 # delimiter to the suffix-comma form alone (C10's core-is-a-word 

533 # Accepted entry) 

534 cores = delimiter_cores(pol.extra_suffix_delimiters) 

535 inner = glued_cores(pol.extra_suffix_delimiters) 

536 whole: bool | None = None 

537 core_idx: list[int] = [] 

538 # a loop, and the head counted only where a core stands alone: the 

539 # built-in ';', '-' and '/' leave `cores` non-empty for every 

540 # policy (D15, #889) 

541 if len(seg1) > 1: 

542 for i in seg1: 

543 if tokens[i].text in cores: 

544 core_idx.append(i) 

545 if core_idx: 

546 whole = _whole_name(seg0, tokens, state.one_case) 

547 if not whole: 

548 core_idx = [] 

549 # a set for the membership test: a list scanned per token was 

550 # quadratic in the part (#613's PR review, 'MD / '*k under '/') 

551 core_set = frozenset(core_idx) 

552 part = [i for i in seg1 if i not in core_set] if core_idx else list(seg1) 

553 if not part: 

554 return state 

555 if pol.unlisted_dotted_suffixes: 

556 # rules/S3: "Paired initials are the exception [...] where S4 

557 # would count words to spare for them they are the name word 

558 # they are written as" -- classify holds the class tag back from 

559 # them, and at this comma C5 reads them instead (S3's Precedence 

560 # bullet), so they rejoin the class for C5's count. Shape-tagged 

561 # and untagged with the switch on is exactly that word 

562 # (classify's dotted branch), asked of the tags in C 

563 # (decisions/S3 (#864)). 

564 for i in part: 

565 tags = tokens[i].tags 

566 if (SHAPE_ACRONYM_TAG in tags 

567 and AMBIGUOUS_ACRONYM_TAG not in tags): 

568 tokens[i] = copy_with( 

569 tokens[i], tags=tags | {AMBIGUOUS_ACRONYM_TAG}) 

570 p1, pt1 = _reading_pieces(part, tokens, splits is not False) 

571 anchored: list[int] = [] 

572 absorbed: list[int] = [] 

573 # A core in the part, standing alone or glued to a word or inside 

574 # one: either keeps the part from the listing form's early return, 

575 # and only a glued one reads behind any head, a core standing alone 

576 # being a word behind one name word (C10's Accepted bullet). The 

577 # shape tests below read a word with one glued at an end as the word 

578 # without it, as classify tags it ('MBBS;' as 'MBBS'). A loop, so 

579 # the listing form pays no frame for it. 

580 cored = glued = False 

581 texts: dict[int, str] = {} 

582 for i in part: 

583 text = texts[i] = tokens[i].text 

584 here = False 

585 if text in cores: 

586 cored = True 

587 else: 

588 for core in inner: 

589 if core in text: 

590 cored = True 

591 if glues(core): 

592 here = glued = True 

593 if here: 

594 stem = glued_stem(text, inner) 

595 if stem is not None: 

596 texts[i] = stem 

597 # The words of the class in the part, and the unlisted all-caps 

598 # words the caps shape could admit: only a part holding one asks 

599 # the count, so 'Smith, John' and 'John Smith, PhD' never build it. 

600 # A split credential is a word of the class too (rules/S3), its 

601 # piece tag the test. 

602 maybe = [p[0] for p, pt in zip(p1, pt1) if "split" in pt 

603 or len(p) == 1 

604 and (AMBIGUOUS_ACRONYM_TAG in (tags := tokens[p[0]].tags) 

605 or ("vocab:suffix" not in tags 

606 and (texts[p[0]].isupper() 

607 and (texts[p[0]].isalpha() 

608 # the joined spelling ('PU-PH'), the 

609 # predicate's other half (rules/S9) 

610 or "-" in texts[p[0]] 

611 or "/" in texts[p[0]]) 

612 or not unicodedata.is_normalized( 

613 "NFC", texts[p[0]]) 

614 and is_caps_word(texts[p[0]]))))] 

615 # rules/C16: "behind one word, initials run together are the given 

616 # name whatever they spell" -- the runs that spell a listed 

617 # credential ('J.P.', 'M.A.'), joined to the class so C4's count is 

618 # taken (decisions/C16 (#1238)). Gathered only 

619 # where the part holds a full stop, so 'Smith, John' pays no frame; 

620 # a part whose other words are all credentials is C8's run 

621 # ('Smith, M.D. Ph.D.'). 

622 runs: set[int] = set() 

623 at = -1 

624 for at, p in enumerate(p1): 

625 if len(p) == 1 and "." in (text := texts[p[0]]): 

626 tags = tokens[p[0]].tags 

627 if ("vocab:suffix" in tags 

628 and SHAPE_ACRONYM_TAG not in tags 

629 and is_letter_run(text) 

630 # every letter a capital ('J.씨' is a glued 

631 # honorific; NFC first, a combining mark being no 

632 # capital), or one case through the whole name; map 

633 # over a builtin, so no frame per letter 

634 and (all(map(str.isupper, letters := unicodedata.normalize( 

635 "NFC", text.replace(".", "")))) 

636 or state.one_case 

637 and all(map(str.islower, letters)))): 

638 # the first run is the one asked about; another run 

639 # beside it is company like any credential ('Anderson, 

640 # M.T. B.A.') 

641 runs.add(p[0]) 

642 break 

643 contrast_box: list[bool] = [] 

644 if runs: 

645 first = p1[at][0] 

646 # titles in front of the run are the given part's (C16); 

647 # behind it a credential is company: a title/suffix dual only in 

648 # capitals ('Smith, M.D. MS', but 'Smith, J.P. Ms.'), never a 

649 # generation ('Rinehart, J.D. Jr.'), a spaced delimiter none 

650 # ('Smith, M.D. / PhD'), and an unlisted capitals word where C6 

651 # finds the name's contrast ('Smith, M.D. FRCS') 

652 rest = [(p, pt) for k, (p, pt) in enumerate(zip(p1, pt1)) 

653 if (k > at or k < at and not is_title_piece(p, pt, tokens)) 

654 and not (len(p) == 1 and texts[p[0]] in cores)] 

655 if rest and all(_company(state, seg0, p, pt, texts, contrast_box) 

656 for p, pt in rest): 

657 runs.clear() 

658 elif first not in maybe: 

659 maybe.append(first) 

660 # rules/C8: "a part that is nothing but suffix words is the 

661 # credential run and reads as suffixes, whole" -- the reading below. 

662 # The part is read ONCE: here where it holds no word of the class, 

663 # after the count below where it does, with the words the count 

664 # licensed (a read here and a second after licensing threw the first 

665 # away, #613's /simplify). A part holding a word no vocabulary 

666 # claims as a suffix, and none of the class, is the given part: the 

667 # listing form stands. (a declared delimiter's core may sit inside a 

668 # word, 'RN/CRNA', which only the lenient fallback below reads) 

669 reading: tuple[bool, ...] | None = None 

670 # rules/C12's evidence, filtered by the tag and a C-level match so 

671 # the listing form pays no frame for it ('Smith, John' never calls 

672 # the helper) 

673 generation = bool(seg0) and ( 

674 "vocab:suffix-word" in tokens[seg0[-1]].tags 

675 or ROMAN.match(tokens[seg0[-1]].text.rstrip(".")) is not None) 

676 behind_generation = listing = False 

677 # C6's contrast, where the run's company has already asked it 

678 contrast: bool | None = contrast_box[0] if contrast_box else None 

679 if not maybe: 

680 reading = segment_suffix_reading( 

681 p1, pt1, tokens, pol.lenient_comma_suffixes, state.one_case, 

682 anchored, absorbed) 

683 # a word no vocabulary claims as a suffix: the listing form, 

684 # unless C12 reads it below, which skips C1's fallback for it 

685 listing = reading is None and not all( 

686 "suffix" in t or "vocab:suffix" in tokens[p[0]].tags 

687 for p, t in zip(p1, pt1)) 

688 # a part holding a delimiter core ('MD;', 'MD ; PhD') is left 

689 # to C1's run test below, which reads a core alone as no word 

690 # and a word with one glued as the word without it (rules/C10) 

691 if cored: 

692 listing = False 

693 if listing and not generation: 

694 return state 

695 names = 0 

696 caps: set[int] = set() 

697 licensed: set[int] = set() 

698 run = False 

699 if maybe: 

700 # `_members` inline: a frame here is paid by every name with the 

701 # ambiguous class after its comma ('John Smith, MA') 

702 split_at: set[int] = set() 

703 for k, i in enumerate(seg0): 

704 if SPLIT_MEMBER_TAG in tokens[i].tags: 

705 split_at.add(k) 

706 names = name_word_count([tokens[i].text for i in seg0], lex, pol, 

707 letters_one_case=state.one_case, 

708 members=split_at) 

709 if names >= 2: 

710 # rules/C6: "An unlisted all-caps word joins the ambiguous 

711 # credential class [...] only where the name carries the contrast" 

712 for i in maybe: 

713 text = texts[i] 

714 if SHAPE_ACRONYM_TAG in tokens[i].tags: 

715 # tagged by classify (EVERYWHERE); the dotted shape has 

716 # a period 

717 if "." not in text: 

718 caps.add(i) 

719 continue 

720 if (AMBIGUOUS_ACRONYM_TAG not in tokens[i].tags 

721 and pol.unlisted_caps_suffixes is not CapsSuffixes.OFF 

722 and caps_shape_candidate(text, lex, pol, one_case=False)): 

723 if contrast is None: 

724 contrast = _name_contrast(state, seg0) 

725 # a two-capital word is how initials are written: the 

726 # paired-initials rule below discards one nothing speaks 

727 # for, a lone one included ('John Smith, XY') 

728 if contrast: 

729 caps.add(i) 

730 # rules/C4: "The same count reads a part of two or more words 

731 # as the credential run when every word of it is a suffix word 

732 # or a word of this class", none of them a single-letter numeral. 

733 # the words not of the class are asked as C1's suffix test asks 

734 # them, the lenient word test by default ('Ma B.') 

735 others: list[str] = [] 

736 for p, pt in zip(p1, pt1): 

737 if ("split" in pt 

738 or len(p) == 1 

739 and (AMBIGUOUS_ACRONYM_TAG in tokens[p[0]].tags 

740 or p[0] in caps)): 

741 licensed.add(p[0]) 

742 else: 

743 # a loop, not a comprehension: a listcomp is a frame 

744 # per piece on 3.11 

745 for i in p: 

746 others.append(tokens[i].text) 

747 run = (bool(licensed) 

748 and (not others or is_wholly_suffix( 

749 others, lex, pol, letters_one_case=state.one_case)) 

750 and not any(len(p) == 1 

751 and is_single_letter_numeral(texts[p[0]]) 

752 for p in p1)) 

753 if not run: 

754 licensed.clear() 

755 else: 

756 # rules/C5: "Paired initials are the exception to the [...] 

757 # count" -- "Only an unambiguous suffix word in front of 

758 # them that is not also title vocabulary, or another word 

759 # the class admits by its dotted shape standing in the same 

760 # part, makes them the credential run" 

761 shape = {i for i in licensed 

762 if i in caps or SHAPE_ACRONYM_TAG in tokens[i].tags} 

763 # Where the first such suffix word stands, found once and 

764 # only when a pair asks: a pair is spoken for exactly when 

765 # it stands behind that word, so one scan answers every 

766 # pair. Asked per pair, the scan re-walked every word in 

767 # front of each one, quadratic in the part ('John Smith, ' + 

768 # 'RA '*k + 'G.J. '*k, the duals never ending it; #563's 

769 # shape, back in #613 and caught in review) 

770 speaker = -1 

771 for k, p in enumerate(p1): 

772 i = p[0] 

773 if len(p) != 1 or i not in licensed: 

774 continue 

775 text = texts[i] 

776 if not (is_paired_initials(text) 

777 or (i in caps and composed_len(text) == 2)): 

778 continue 

779 if speaker < 0: 

780 speaker = next( 

781 (j for j in range(len(p1)) 

782 if is_suffix_piece(p1[j], pt1[j], tokens) 

783 and not is_title_piece(p1[j], pt1[j], tokens)), 

784 len(p1)) 

785 # another shape word than this one: no set built per 

786 # pair (a copy of `shape` each time was quadratic in C) 

787 if not (speaker < k or len(shape) > 1 

788 or (shape and i not in shape) 

789 # rules/C5: "undotted, two capitals followed by 

790 # credentials and standing beside nothing else 

791 # are the credential run" ('Jane Doe, BM PhD'); 

792 # asked of the one shape word alone, so once 

793 # per part (decisions/C5 (#1323)) 

794 or "." not in text and k + 1 < len(p1) 

795 and _credentials_beside(state, seg0, p1, pt1, k, 

796 texts, contrast_box)): 

797 licensed.discard(i) 

798 caps.discard(i) 

799 if maybe: 

800 lone = bool(runs) and names < 2 and _lone_word(state, seg0) 

801 reading = segment_suffix_reading( 

802 p1, pt1, tokens, pol.lenient_comma_suffixes, state.one_case, 

803 anchored, absorbed, licensed, head_named=names >= 2, 

804 runs=runs if lone else ()) 

805 if reading is None and lone: 

806 # rules/C16: "behind one word, initials run together are the 

807 # given name whatever they spell" -- the run the reading 

808 # stopped at, the part's first piece that is no title (a 

809 # name word in front of the run made the part the given part 

810 # itself, and the run is that part's to read). Asked before 

811 # C10's glued reading, so 'Smith, J.P.;' is the given name as 

812 # its unlisted twin 'Smith, X.Q.;' is 

813 k = next((j for j, p in enumerate(p1) 

814 if not is_title_piece(p, pt1[j], tokens)), -1) 

815 if k >= 0 and p1[k][0] in runs: 

816 return _run_read_as_initials(state, p1[k][0]) 

817 if not listing: 

818 if whole is None: 

819 whole = _whole_name(seg0, tokens, state.one_case) 

820 # rules/C1: "By default a recognized suffix word counts even 

821 # written like an initial" -- behind a whole name ('John Smith, V.') 

822 # rules/C10: "A delimiter glued to a suffix word parts it from 

823 # the next as a comma glued there would" -- the part "reads as 

824 # it does with a space in the delimiter's place", behind any 

825 # head, so 'Smith, MD; PhD' reads as 'Smith, MD PhD' does; behind 

826 # one that is not a whole name by the strict test, where "A 

827 # glued word written like an initial is the given name" 

828 # (decisions/C10, D15) 

829 if reading is None and (whole or glued) and is_wholly_suffix( 

830 [tokens[i].text for i in part], lex, pol, one_case=None, 

831 letters_one_case=state.one_case, strict=not whole): 

832 reading = (True,) * len(p1) 

833 # the words of the part C12 reads as suffixes, which its report 

834 # names: a title in the part, a word or a joined run, is no 

835 # credential it took 

836 taken: list[int] = [] 

837 if reading is None and generation: 

838 reading = _behind_a_generation(state, seg0, p1, pt1, tokens, 

839 contrast) 

840 if reading is not None: 

841 behind_generation = True 

842 for p, suffix in zip(p1, reading): 

843 if not suffix: 

844 continue 

845 for i in p: 

846 taken.append(i) 

847 text = texts[i] 

848 # #564's marks on an unlisted all-caps word, as the 

849 # caps shape writes them, so case repair keeps 'AD' 

850 # (rules/R4); the C-level filter `maybe` uses first 

851 if ("vocab:suffix" not in tokens[i].tags 

852 and (text.isupper() 

853 and (text.isalpha() or "-" in text 

854 or "/" in text) 

855 or not unicodedata.is_normalized( 

856 "NFC", text) and is_caps_word(text))): 

857 caps.add(i) 

858 if reading is None: 

859 return state 

860 # rules/C7: "that reading is final" -- the part is the postnominal 

861 # part, bound here whatever stands before the comma. The comma is a 

862 # suffix comma behind a whole name; behind one name word it stays 

863 # the family comma naming the family. 

864 ambiguities = list(state.ambiguities) 

865 for k, piece in enumerate(p1): 

866 role = Role.SUFFIX if reading[k] else Role.TITLE 

867 for i in piece: 

868 tags = tokens[i].tags 

869 if i in caps: 

870 # #564: the caps shape's marks, as classify writes them 

871 # EVERYWHERE, so case repair keeps 'XYZ' (rules/R4) 

872 tags = tags | {SHAPE_ACRONYM_TAG, AMBIGUOUS_ACRONYM_TAG} 

873 tokens[i] = copy_with(tokens[i], role=role, tags=tags) 

874 # rules/C11: "A decision either way at the comma C1 reads is reported; 

875 # for a run of words the decision is the flip to the credential 

876 # run, reported once over the whole part" -- its Then, which this 

877 # implements, with rules/C11's Then bullet "A run the count leaves 

878 # in the listing form reports as S7 reads the words in it" and 

879 # rules/C11's Except bullet "A flip in which no listed word of the 

880 # ambiguous credential class (C4) takes part is the exception and 

881 # is made in silence" -- "unless what said so is nothing but other 

882 # paired initials", the clause that bullet ends on 

883 # (decisions/C11 (#544)). A run whose every class word is listed and settled by 

884 # its capitals is read on that evidence, not flipped by the count. 

885 # A caps word bound above carries the class tags now, so membership 

886 # is the tag alone; a part with no word of the class has none, and 

887 # skips the bookkeeping (three list comprehensions, a frame each on 

888 # 3.11, on every bound part) 

889 members: list[int] = [] 

890 listed: list[int] = [] 

891 settled = pair_only = False 

892 if maybe: 

893 members = [p[0] for p in p1 if len(p) == 1 

894 and AMBIGUOUS_ACRONYM_TAG in tokens[p[0]].tags] 

895 listed = [i for i in members 

896 if SHAPE_ACRONYM_TAG not in tokens[i].tags] 

897 settled = (bool(members) 

898 and len(listed) == len(members) 

899 and all(listed_lean(tokens[i], state.one_case) 

900 == "credential" 

901 and (names >= 2 

902 or AMBIGUOUS_WORD_TAG not in tokens[i].tags) 

903 for i in listed) 

904 # rules/S6: "a clash settled by the writing is not 

905 # reported" -- a lone word only where it is an 

906 # acronym its capitals settle 

907 and (len(p1) > 1 

908 or settled_by_case(tokens[listed[0]], 

909 state.one_case))) 

910 member_set = frozenset(members) 

911 pairs = [k for k, p in enumerate(p1) if p[0] in member_set 

912 and (is_paired_initials(texts[p[0]]) 

913 or (p[0] in caps 

914 and composed_len(texts[p[0]]) == 2))] 

915 # spoken for by each other only: every class word a pair, and 

916 # only titles among the other words in front of the first 

917 pair_only = (len(pairs) >= 2 and len(pairs) == len(members) 

918 and all(p[0] in member_set 

919 or is_title_piece(p, pt, tokens) 

920 for p, pt in zip(p1[:pairs[0]], pt1))) 

921 part_reported = behind_generation 

922 if behind_generation: 

923 # rules/C11: "A decision either way at the comma C1 reads is 

924 # reported" -- here the suffix word before the comma decided it, 

925 # not C4's count, which C12 does not consult 

926 ambiguities.append(PendingAmbiguity( 

927 AmbiguityKind.SUFFIX_OR_NAME, 

928 f"{' '.join(tokens[i].text for i in taken)!r} after the comma " 

929 f"follows {tokens[seg0[-1]].text!r}, so it is read as a " 

930 f"credential run; it may be a name", tuple(taken), 

931 alternative=_given_part(tokens[taken[0]]))) 

932 elif whole and run and ((listed and not settled) or caps or pair_only): 

933 part_reported = True 

934 if len(part) == 1: 

935 what = (f"{tokens[part[0]].text!r} after the comma is also an " 

936 f"ordinary name word") 

937 else: 

938 what = (f"{' '.join(tokens[i].text for i in part)!r} after the " 

939 f"comma holds a word that is also an ordinary name word") 

940 ambiguities.append(PendingAmbiguity( 

941 AmbiguityKind.SUFFIX_OR_NAME, 

942 f"{what}; the part before the comma holds {names} name words, " 

943 f"so it is read as a credential run", tuple(part), 

944 alternative=_given_part(tokens[part[0]]))) 

945 elif (len(p1[0]) == 1 and p1[0][0] in listed 

946 and not (reading[0] 

947 and settled_by_case(tokens[p1[0][0]], state.one_case))): 

948 # the first word after the comma reports as it always does, 

949 # naming the role bound above: the listing form binds a word 

950 # the reading declines as a title ('John, Si.', #793), and no 

951 # later rule moves a word of the emptied part; one its capitals 

952 # settled as the credential does not 

953 i = p1[0][0] 

954 field = "a credential" if reading[0] else "a title" 

955 ambiguities.append(PendingAmbiguity( 

956 AmbiguityKind.SUFFIX_OR_NAME, 

957 f"{tokens[i].text!r} after the comma is also an ordinary name " 

958 f"word; read as {field}", (i,), 

959 # the fork is credential or name: a word the reading 

960 # declined is bound as a title, and its other branch is the 

961 # credential 

962 alternative=(_given_part(tokens[i]) if reading[0] 

963 else suffix_reading(tokens[i])))) 

964 # rules/S7: "A member the company decides reports the fork as a 

965 # counted pick does" -- reported where it decided: a member the 

966 # anchor read as a credential after its own writing declined (#544) 

967 for k in anchored: 

968 i = p1[k][0] 

969 ambiguities.append(PendingAmbiguity( 

970 AmbiguityKind.SUFFIX_OR_NAME, 

971 f"{tokens[i].text!r} behind a credential after the comma is " 

972 f"also an ordinary name word; read as a credential", (i,), 

973 alternative=_given_part(tokens[i]))) 

974 # rules/C13: "A credential opening the part after a family comma 

975 # makes it the postnominal part however it goes on" -- each word it takes that 

976 # would have made it the given part, as #602's run reports them 

977 for k in absorbed: 

978 # a split credential the run takes reports itself, below 

979 if has_name_content(p1[k], tokens) and "split" not in pt1[k]: 

980 text = " ".join(tokens[i].text for i in p1[k]) 

981 ambiguities.append(PendingAmbiguity( 

982 AmbiguityKind.SUFFIX_OR_NAME, 

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

984 f"suffix run; it may be a name word", tuple(p1[k]), 

985 alternative=_given_part(tokens[p1[k][0]]))) 

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

987 # -- C4's count took it, unless the flip it took part in reported 

988 # the part 

989 if not part_reported: 

990 for k, piece in enumerate(p1): 

991 if "split" in pt1[k] and reading[k]: 

992 ambiguities.append( 

993 split_credential_report(piece, tokens, 

994 _given_part(tokens[piece[0]]))) 

995 # Every word of the part is bound above or dropped as a core, so the 

996 # segment hands group nothing to join -- emptied here, where the 

997 # binding is, as #601's take removes what it binds, rather than 

998 # filtered out again in group (#613's /simplify) 

999 return copy_with(state, tokens=tuple(tokens), 

1000 segments=state.segments[:1] + ((),) 

1001 + state.segments[2:], 

1002 structure=(Structure.SUFFIX_COMMA 

1003 if whole or behind_generation 

1004 else Structure.FAMILY_COMMA), 

1005 ambiguities=tuple(ambiguities), 

1006 dropped=tuple(state.dropped) + tuple(core_idx))