1"""Stage: script_segment (#271, #272, #308, #312).
2
3Consumes: tokens, segments, structure, interpunct_offsets, segmenter.
4Produces: tokens, by two independent splits into sub-slices -- a
5listed honorific peeled off the END of the name's last
6non-post-nominal token, in whichever of the name's runs that falls
7(the tail token also carries this module's _PEELED_TAG), and the
8first activated-script token of the name segment split into n+1 --
9segments (index runs remapped past the insertions), ambiguities
10(indices likewise remapped, plus a SEGMENTATION report when more than
11one split was vocabulary-supported, or when a segmenter's answer
12scored under the confidence floor).
13Reads: Policy.segment_scripts, Lexicon.surnames,
14Lexicon.honorific_tails, ParseState.segmenter, and Lexicon suffix
15vocabulary through TWO predicates, which are NOT one another's
16singular and plural. _vocab.is_suffix_strict asks whether a single
17token is a post-nominal, initial veto included (the peel's scan-back
18and the surname site). _vocab.is_wholly_suffix asks segment's own
19suffix-comma question of a whole RUN, through the POLICY-selected
20token test plus period_joined_vocab, delimiter handling and the
21Ph./D. merge -- so the run predicate says yes both to tokens the
22token predicate VETOES ("V.", "V", "I") and to tokens it never sees
23as suffixes at all ("Msc.Ed.", "J.씨", which reach it through
24period_joined_vocab). The initial-shaped words are the class #319 was
25ABOUT, not the whole of the disagreement, and the extra routes listed
26above are where the rest of it comes from;
27test_is_wholly_suffix_is_not_the_plural_of_is_post_nominal pins the
28"V." half. The run predicate is how the peel declines a run that is
29not name text (#319), but only where the name's own run offers a
30site, since a glued honorific is itself part of what makes a run read
31as suffix-shaped; it owns two further Policy fields
32(lenient_comma_suffixes picks the strict or lenient token test -- it
33flips "田中さん, V." -- and extra_suffix_delimiters both counts a bare
34delimiter-core token as a suffix and splits a token on a core, the
35split being the half that flips "田中さん, Jr./V." under {"/"} and the
36bare core the half that flips "田中さん, /").
37
38Implements rules W1 (the vocabulary/segmenter division), W2 (the
39glued-honorific peel) and W3 (the writer's divisions are respected)
40of docs/design/rules.md, cited at their code below; the decision
41chain (#308, #312, #319, the vetting bars, the measured
42spaced-honorific trade) is decisions.md#W1, #W2 and #W3. Both splits
43make sub-slices of one token, rewriting nothing -- spans still index
44the original exactly, so the anti-#100 invariant holds by
45construction. Both also match on the token's CORE, its
46text.rstrip(FULL_STOPS) (#323) -- rstrip and never strip, because
47both offsets are measured from the text's START, so the core has to
48stay a prefix of the text for the offset the match yields to land
49where the match did. The peel runs FIRST, so suffix classification
50can claim the tail and the surname match or segmenter consult sees
51the name rather than name-plus-honorific; its
52ASCII bail sits above everything here, so a caller-added LATIN tail
53fires only on a name carrying at least one non-ASCII character (see
54the bail's own comment, and honorific_tails' field note).
55"""
56from __future__ import annotations
57
58import dataclasses
59import functools
60from collections.abc import Sequence
61
62from nameparser._lexicon import FULL_STOPS
63from nameparser._pipeline._state import (
64 ParseState, PendingAmbiguity, Structure, WorkToken,
65)
66from nameparser._pipeline._vocab import (
67 effective_script, is_suffix_strict, is_wholly_suffix,
68)
69from nameparser._types import AmbiguityKind, Segmentation, Span
70
71#: Marks the tail token the peel below MANUFACTURED, so the segmenter's
72#: neighbour test can tell it from a token somebody wrote. Namespaced,
73#: therefore unstable provenance rather than API (_types.STABLE_TAGS is
74#: the whole stable set, and FOLDED_TAG is the precedent for a
75#: structural marker carrying this prefix); it is vocabulary-derived
76#: besides, since honorific_tails is what licensed the split. Emitter
77#: and reader are both in this module, so unlike FOLDED_TAG it needs no
78#: home in _types.
79_PEELED_TAG = "vocab:peeled-honorific"
80
81#: Segmenter answers scoring below this attach a SEGMENTATION report
82#: (amendment 2026-07-29 section 3). Kept at the drafted 0.9 after
83#: measuring namedivider 0.4.1 over 112 names (#272 Task 5), which
84#: found a distribution the amendment did not anticipate: the scores
85#: are BIMODAL, not clustered near 1. A rule-based division (the kana
86#: boundary in 高橋みなみ, or a two-character name) scores exactly 1.0;
87#: a kanji-statistics division scores a softmax over the candidate cut
88#: positions, observed in 0.23-0.68 and driven more by name LENGTH
89#: (median 0.61 at three characters, 0.37 at five -- the softmax is
90#: over len-1 candidates) than by correctness: the wrong answers in
91#: the sample scored 0.32/0.60/0.60/0.61, straddling the correct
92#: median of 0.52. So no floor INSIDE that band separates error from
93#: success, and the only cut the data supports is between a stated
94#: certainty and a statistical guess -- which is what section 3's
95#: epistemic argument asks for anyway. 0.9 sits in the empty gap
96#: (0.68 to 1.0), far from both modes, and reads as "confident" for a
97#: third-party segmenter with a calibrated score too. Consequence,
98#: pinned by tests and stated so it can be checked: every
99#: STATISTICALLY divided name carries a SEGMENTATION report, and every
100#: RULE-divided one -- the kana boundary, a two-character name,
101#: namedivider's specific-name rules -- carries none.
102#: namedivider scores its own two-character rule 1.0, so this floor
103#: keeps that division silent -- see locales/ja.py for why the
104#: presumption is accepted as stated.
105#: Not configurable in 2.x (YAGNI).
106_SEGMENTER_CONFIDENCE_FLOOR = 0.9
107
108
109def _remap(run: tuple[int, ...], split_at: int,
110 added: int) -> tuple[int, ...]:
111 """One segment run after token `split_at` split into `added` + 1
112 pieces: the extra pieces join the run immediately after it, and
113 every later index shifts by as many."""
114 out: list[int] = []
115 for j in run:
116 if j == split_at:
117 out.extend(range(split_at, split_at + added + 1))
118 else:
119 out.append(j + added if j > split_at else j)
120 return tuple(out)
121
122
123def _pieces(text: str, splits: tuple[int, ...]) -> tuple[str, ...]:
124 """`text` cut at every offset in `splits`: n offsets, n+1 pieces."""
125 cuts = (0, *splits, len(text))
126 return tuple(text[a:b] for a, b in zip(cuts, cuts[1:]))
127
128
129def _split(state: ParseState, i: int, splits: tuple[int, ...],
130 detail: str | None, tail_tag: str | None = None) -> ParseState:
131 """Cut token `i` at every offset in `splits`, recording `detail` as
132 a SEGMENTATION report when there is one, and adding `tail_tag` to
133 the LAST piece when there is one of those.
134
135 The offsets arrive non-empty, ascending and interior whatever chose
136 them. From a segmenter: non-empty is the caller's own check,
137 strictly ascending with each >= 1 is Segmentation.__post_init__'s,
138 and the last offset is < len(text) -- checked by the caller. From
139 the vocabulary: the single offset is >= 1 and < len(text) by the
140 range(cap, 0, -1) construction and its len-1 cap.
141
142 The ONE split path: the vocabulary hit is the single-offset case
143 and the segmenter's answer the general one, so neither can drift
144 from the other's index arithmetic. `tail_tag` is part of keeping it
145 one path: the piece is tagged HERE, where it is built, rather than
146 by the caller, which would have to re-derive where its own tail
147 landed after handing the index arithmetic off. Only the peel passes
148 one, and the piece it wants is the last by construction -- the
149 honorific it cut off the end; the surname path passes nothing and
150 the default leaves it exactly as it was."""
151 token = state.tokens[i]
152 base = token.span.start
153 parts: list[WorkToken] = []
154 start = 0
155 for piece in _pieces(token.text, splits):
156 end = start + len(piece)
157 parts.append(dataclasses.replace(
158 token, text=piece, span=Span(base + start, base + end)))
159 start = end
160 if tail_tag is not None:
161 parts[-1] = dataclasses.replace(
162 parts[-1], tags=parts[-1].tags | {tail_tag})
163 added = len(splits)
164 tokens = state.tokens[:i] + tuple(parts) + state.tokens[i + 1:]
165 # Every index the earlier stages recorded is now stale past the
166 # split point: the segment runs group's own iteration rests on, and
167 # the ambiguities from extract_delimited (resolved to indices by
168 # tokenize) and segment. An ambiguity ON the split token keeps
169 # pointing at the head.
170 segments = tuple(_remap(run, i, added) for run in state.segments)
171 ambiguities = tuple(
172 dataclasses.replace(a, indices=tuple(
173 j + added if j > i else j for j in a.indices))
174 for a in state.ambiguities)
175 if detail is not None:
176 ambiguities += (PendingAmbiguity(
177 AmbiguityKind.SEGMENTATION, detail,
178 tuple(range(i, i + added + 1))),)
179 return dataclasses.replace(state, tokens=tokens, segments=segments,
180 ambiguities=ambiguities)
181
182
183@functools.lru_cache(maxsize=16)
184def _longest_entry(entries: frozenset[str]) -> int:
185 """The longest entry in a vocabulary, cached per-vocabulary rather
186 than recomputed per parse (Lexicon is frozen and slotted, so it
187 cannot carry a cached_property of its own). The frozenset is
188 hashable and a process holds only a handful of distinct
189 vocabularies -- the default one, plus one per constructed pack
190 parser -- so maxsize=16 bounds pathological many-lexicon churn
191 without ever evicting in normal use. Keyed by the frozenset VALUE,
192 not by (lexicon, field): the two callers pass surnames and
193 honorific_tails, but every lexicon that leaves KOREAN_SURNAMES
194 alone shares one entry, so the count is distinct SETS rather than
195 lexicons times fields.
196
197 Callers must pass a NON-EMPTY vocabulary: max() of an empty set
198 raises, and both call sites sit under a match guard that cannot
199 reach it with one."""
200 return max(map(len, entries))
201
202
203def _is_post_nominal(state: ParseState, i: int) -> bool:
204 """Whether token `i` is post-nominal VOCABULARY. Two of this
205 stage's decisions ask it and neither is about script: an honorific
206 is neither a surname SITE nor the token a glued honorific hangs off
207 (#308).
208
209 It answers a VOCABULARY question, not a positional one, and the two
210 callers do not spend the answer the same way. In TRAILING position
211 vocabulary and position agree, so the peel site steps over a True
212 and goes on scanning. In LEADING position they disagree -- 양 is
213 the family name there, whatever the suffix set says -- so the
214 surname site reads a True as an ANSWER and declines, rather than as
215 a token to step past.
216
217 STRICT, not lenient: the initial veto applies, so "V." is not a
218 post-nominal here though bare "v" is a suffix word. That agrees
219 with what classify does with the same token downstream -- "V." is
220 a middle initial -- and the difference is reachable under the
221 default lexicon: "田中さん V." stops its scan-back at the initial
222 and does not peel, while "田中さん II" steps over II and does. That
223 pair is the case table's ja_honorific_glued_before_an_initial and
224 ja_honorific_glued_before_a_roman_suffix, added because the clause
225 could NAME the discriminating input while swapping in
226 is_suffix_lenient still failed no test -- the shape a "unify the
227 two suffix predicates" refactor would have walked straight past.
228
229 Suffixes only, deliberately. Should a CJK entry ever join titles,
230 the surname site would want it excluded while the peel site must
231 not: a title never trails, so widening the peel's scan-back would
232 move it off the real last name token. Split the predicate then, not
233 before."""
234 return is_suffix_strict(state.tokens[i].text, state.lexicon)
235
236
237# rules.md#W2: "A part that is not name text — a post-nominal word
238# standing on its own — is never the name's end: the split-off steps
239# past it to the name word behind, and never dissects it." (history:
240# decisions.md#W2)
241# The scan-back below is that clause, and it needs no punctuation to
242# fire. '김민준 박사님' steps past 박사님 to 김민준, finds no listed
243# tail there and returns None -- so 박사님 is left whole for suffix
244# classification rather than cut into 박사 + 님, which is what the
245# same input gives when the step is removed. '선생님' is post-nominal
246# entire with no name word behind it, so the scan yields no site at
247# all and the token stays whole. Both are contract-tier corpus names
248# and rules.md#W2 example lines; decisions.md#cjk-comma-demotion
249# carries the forced-predicate measurement behind them.
250def _peel_site(state: ParseState, flat: Sequence[int],
251 tails: frozenset[str]) -> tuple[int, int] | None:
252 """Where a peel would land in the token run `flat`: the index of the
253 token to cut and the OFFSET to cut it at (the listed tail, and any
254 full stops riding behind it, are what comes off the end), or None
255 where that run offers no peel.
256
257 Two callers, one answer, which is the point of naming it. The peel
258 itself asks it once, of the runs it decided to scan. The gate above
259 that decision asks it of segments[0] ALONE, to find out whether
260 declining the second run would cost the only site. Sharing the scan
261 with the peel rather than approximating it there is what makes the
262 gate's answer mean what it says: a gate that merely looked for a
263 token ENDING in a listed tail would count a token that is a tail
264 entire, which the scan-back skips as a site -- "선생님, J.씨" would
265 decline on a site the peel then cannot use, and lose the peel in
266 exactly the way the gate exists to prevent. (Only the lone-token
267 shape of that divergence is reachable from the gate, and the gate's
268 own other conjunct is what makes that so: SUFFIX_COMMA wants BOTH
269 a suffix-shaped second run and more than one word before the
270 comma, and the gate is asked only where the first of those is
271 already true -- so a run this call ever sees under FAMILY_COMMA
272 failed on the second, and segments[0] holds at most one token. The
273 word-count alone would not say that: "Dr 김민준, 지훈" has two
274 words before the comma and is FAMILY_COMMA.)
275
276 Callers must pass a NON-EMPTY `tails` -- _longest_entry's
277 precondition, which the stage's own early return supplies."""
278 i = next((j for j in reversed(flat)
279 if not _is_post_nominal(state, j)), None)
280 if i is None:
281 # nothing but post-nominals, or no tokens at all
282 return None
283 text = state.tokens[i].text
284 # The tail is matched on the token's core (#323): a stop glued after
285 # the honorific ('김민준씨.', '田中さん.') stands between the listed
286 # tail and the token's end and used to defeat the match. What the
287 # text read instead depended on the stop: the ASCII spelling went to
288 # a title downstream (H2's shape is ASCII-period-only), while a
289 # fullwidth or ideographic stop left the whole text a lone name word
290 # ('田中さん。' read given). The cut lands BEFORE the tail, so the
291 # stops ride with the honorific piece and the token text is never
292 # rewritten. TRAILING only: a leading stop is not between the name
293 # and its honorific, and the offset returned below is the core's
294 # length less the tail's, one subtraction.
295 core = text.rstrip(FULL_STOPS)
296 # range/cap construction identical to the surname match below, and
297 # for the same two reasons: longest-first, and a len-1 cap that
298 # makes the offset interior by construction (_split's contract). An
299 # empty or one-character core caps below 1 and the loop is empty.
300 cap = min(_longest_entry(tails), len(core) - 1)
301 for length in range(cap, 0, -1):
302 if core[-length:] in tails:
303 return i, len(core) - length
304 return None
305
306
307# rules.md#W2: "a listed honorific glued to the end of the name's
308# last name word splits off once and reads as a suffix." (history:
309# decisions.md#W2)
310# That the peel also reaches ACROSS a family comma is stated at
311# rules.md#W3 instead, which is a tolerated rule since the
312# 2026-09-01 comma demotion -- the crossing is what the parser does
313# today, not something W2 promises. W3 also carries the reading of a
314# period a listing leaves behind (decisions.md#cjk-comma-demotion,
315# amended by decisions.md#cjk-full-stops): a period on a SEPARATE
316# post-nominal word rides into the suffix and moves nothing ('様.'
317# is post-nominal-strict and the scan steps past it as it steps past
318# '様'), and since #323 a period glued to the honorific's OWN token
319# rides with the honorific too -- _peel_site matches the tail through
320# the trailing stop and cuts before it, so '田中さん.' divides where
321# '田中さん' does, and '김민준씨.' where '김민준씨' does (both are
322# case rows now; before 2026-09-10 they read as a title, measured
323# 2026-09-05 and pinned by nothing, and for one commit of the #323
324# branch the surname site read '김민준씨.' as 김 + 민준씨.). Neither
325# reading is a promise; the step past the post-nominal word itself
326# is W2's, above, and is.
327def _peel_honorific_tail(state: ParseState) -> ParseState:
328 """#308: split a listed honorific off the END of the name's last
329 NON-POST-NOMINAL token -- 田中さん -> 田中 + さん -- and let
330 the existing machinery do the rest. Suffix classification claims
331 the tail downstream (every honorific_tails entry is a suffix word
332 too, enforced by Lexicon), and the segmentation half below then
333 sees the remainder rather than the glued whole, so 김민준씨 splits
334 김 + 민준 and a configured segmenter is handed 山田太郎 rather
335 than 山田太郎様.
336
337 Scanning back over post-nominals rather than taking the last token
338 outright does three things at once. An unrelated trailing suffix
339 cannot hide the peel site, so "김민준씨 Jr." answers as the
340 comma-written "Dr 김민준씨, Jr." does -- one name, two spellings,
341 one parse. A token that IS a tail (씨, さん, and the nested 선생님,
342 which the cap alone would peel to 선생 + 님) is skipped as a site
343 and stays whole: every tail is a suffix word by the Lexicon
344 invariant, which is what makes that guard hold, and the cap below
345 only keeps the offset interior for _split. And the scan answers
346 None rather than indexing anything, which two reachable inputs
347 need: a name that is nothing but post-nominals ("씨"), and one
348 whose name runs are empty (", , 씨" scopes to two empty ones).
349 Nothing here rests on a structural gate landing first, then, which
350 is what let #312 move both of the stage's gates below it.
351
352 WHICH tokens are scanned is a separate decision, and the one a
353 reader is likeliest to undo: the NAME's segment runs, flattened,
354 and never state.tokens or every segment. The alternatives agree
355 except where extract_delimited has already claimed a token or a
356 comma has closed the name, both of which this stage can still see
357 -- so "김민준씨 (Jimmy)" and "Dr 김민준씨, V." are the inputs that
358 tell the three apart. See the note at the scan itself.
359
360 That last token is the last of the NAME, which reaches into a
361 maiden clause: maiden tokens are still main-stream here
362 (extract_delimited has masked only bracketed content), so
363 "김민준 née 박씨" peels 씨 off the MAIDEN name 박씨 and hands it to
364 the person's suffix list -- "née Ms. Park". Intended rather than
365 incidental in that direction: the honorific is the reader's
366 regardless of which of her names it was glued to, and a
367 name-final honorific is exactly what this peels. Since #312 that
368 reach extends to FAMILY_COMMA along with the rest of the crossing
369 -- "김, 민준 née 박씨" now routes 씨 to suffix, where before #312
370 the stage returned at the family-comma gate above the peel and
371 left it in maiden "박씨".
372
373 The other direction is a LIMIT, stated rather than fixed: a maiden
374 clause pushes the site off the person's own name, so "김민준씨 née
375 박" does NOT peel and gives given "민준씨" -- the original bug,
376 intact behind a marker. "김민준씨 née 박씨" shows both at once, two
377 identical honorifics of which only the maiden's is routed. Chasing
378 the marker into the site scan is scope creep for an uncommon input,
379 and it could not be done here anyway: classify has not run, so the
380 marker tokens carry no tag this stage could read.
381
382 Longest-first, and ONE peel: a remainder that itself ends in a
383 listed tail is accepted rather than chased. No SHIPPED input
384 witnesses that any more -- 박사님 was the last one, and adding it
385 is what removed the case: 김민준박사님 now gives up 박사님 entire,
386 since longest-first reaches the whole honorific. The pin needs a
387 tail whose remainder ends in a DIFFERENT tail, the two not
388 themselves a listed entry, and no pair in the shipped vocabulary
389 has that shape; the stage test test_one_peel_never_a_stack carries
390 it on a synthetic lexicon, which is now its only witness. No
391 script precondition on the remainder either, since the tail alone
392 is the license: Andersonさん peels.
393
394 Emits no ambiguity, unlike the surname fork below, though
395 longest-first does CHOOSE here too -- 김선생님 gives 선생님 where
396 님 also matches. The difference is what the runner-up is: a second
397 matching surname is a competing READING of the name, which a
398 caller may prefer, while a shorter tail leaves a remainder that is
399 not a name at all (김선생), so there is nothing to adjudicate."""
400 tails = state.lexicon.honorific_tails
401 if not tails:
402 return state
403 # The NAME's runs, which is more than segments[0] but never every
404 # segment. A comma is how a writer says which runs are the name: a
405 # FAMILY comma splits the name itself across two of them ("김,
406 # 민준씨" is (0,) and (1,)), and the honorific is as often glued to
407 # the given name as to the family, so the peel has to cross that
408 # boundary (#312). Every OTHER structure keeps the whole name in
409 # segments[0], so no boundary has to be crossed to find the site
410 # there -- which is why the asymmetry is the point rather than an
411 # accident of two cases. Note that "the rest is post-nominals" is
412 # true of the SUFFIX comma only: under NO_COMMA segment returns
413 # exactly one run and any trailing post-nominal is INSIDE it
414 # ("김민준씨 Jr." is one run of two tokens), which is why the
415 # scan-back above steps over such a token rather than simply never
416 # reaching it.
417 # The second run is only NAME text when segment read it as one,
418 # which the structure alone does not say: SUFFIX_COMMA also wants
419 # more than one word before the comma, so a one-word part turns a
420 # wholly suffix-shaped remainder into FAMILY_COMMA anyway ("田中さん,
421 # V." is that input). So ask segment's own predicate instead of
422 # inferring the answer from the structure it produced (#319).
423 # is_wholly_suffix, NOT the plural of _is_post_nominal: the two
424 # disagree on the initial-shaped suffix words ("V.", "V", "I"),
425 # which is the class #319 was reported about, and on everything
426 # the run predicate's extra routes reach and the token predicate
427 # does not ("Msc.Ed." and "J.씨" by period_joined_vocab, both of
428 # which this change also moves). Reaching into such a
429 # run put the site on "V.", which ends in no listed tail, so the
430 # peel silently abandoned and さん stayed glued to the family --
431 # while "田中さん, PhD" peeled all along, because "PhD" satisfies
432 # the strict test and the scan-back stepped over it. One credential,
433 # two spellings, two answers FROM THE PEEL -- and the peel's answer
434 # is the only one that moved: the peeled remainder is 田中 and lands
435 # in family under every spelling, while where the CREDENTIAL lands
436 # is assign's question and still differs ("PhD" a title, "V." a
437 # given, "Ph. D." a suffix beside さん).
438 # A junk tail is the worse shape of the same reach: in
439 # "김민준씨, J.씨" the site lands on the junk "J.씨", so master
440 # peeled THAT 씨 and left the person's own glued inside family
441 # "김민준씨". Reachable only where the run is genuinely
442 # suffix-shaped, which "J.씨" is by period_joined_vocab; a run of
443 # ordinary name text is scanned on purpose, and a junk tail further
444 # out than the second run is held off by the scope rule instead
445 # ("Dr 김민준씨, Jr., 박씨" is SUFFIX_COMMA with 박씨 in a third
446 # run, so it never reaches here at all).
447 # An EMPTY second run stays in scope and contributes nothing:
448 # is_wholly_suffix is False on it by its own contract (v1 read
449 # "Doe,, Jr." as a family comma), which is the reading this line
450 # wants anyway -- flattening an empty run adds no site.
451 # Policy(lenient_comma_suffixes=False) keeps the old answer for the
452 # INITIAL-shaped suffixes specifically, which is where the
453 # strict/lenient gap lives: the knob drops this call to the strict
454 # predicate too, so is_wholly_suffix(["V."]) is False, the run reads
455 # as name text, it IS scanned, and the peel is abandoned on "V." as
456 # before -- family "田中さん", given "V.". It is not a blanket
457 # freeze of the old behavior, and "田中さん, Ph. D." is the input
458 # that shows the difference: the Ph./D. merge folds that pair to a
459 # form is_suffix_strict accepts, so the run is declined and the peel
460 # fires under the strict knob as well.
461 # Flattening the SEGMENTS rather than state.tokens is load-bearing
462 # too: extracted nickname and maiden content is in tokens but in NO
463 # segment, and scanning tokens would put the peel site on a
464 # nickname ("김민준씨 (Jimmy)" -> the site becomes Jimmy and
465 # nothing peels). ko_honorific_glued_given_nickname pins that.
466 # And declining takes a SECOND condition: segments[0] must hold a
467 # peel site of its own. is_wholly_suffix reaches period_joined_vocab,
468 # which calls a run suffix-shaped when ANY period-chunk is suffix
469 # VOCABULARY -- and every honorific tail is a suffix word by the
470 # Lexicon invariant, so a glued honorific is itself the evidence.
471 # The predicate is circular at THIS call site alone -- not because
472 # segment asks it any earlier (nothing has peeled at either call)
473 # but because segment SPENDS the answer differently: it reads the
474 # run's shape and stops, the answer being the structure, while the
475 # peel reads the same shape and then decides whether to go strip
476 # the very honorific that produced it. "이, J.씨" reads as wholly suffix
477 # only because of the 씨 the peel exists to remove, and declining a
478 # run that holds the only site does not fall back to some other
479 # site -- it loses the peel outright, and with it the given name,
480 # which lands in suffix as "J.씨". Asking for a site in segments[0]
481 # keeps the #319 answer wherever the peel has somewhere else to go
482 # ("田中さん, V." still declines, さん is right there) and gives the
483 # circular case back to master's reading. The two-honorific input
484 # "김민준씨, J.씨" is where the choice is visible and deliberate:
485 # both runs offer a site, so the decline stands and the person's own
486 # 씨 is peeled rather than the junk one behind the comma.
487 runs = state.segments[:1]
488 if state.structure is Structure.FAMILY_COMMA:
489 second = [state.tokens[j].text for j in state.segments[1]]
490 # a site here is asked about, not used: the offset it carries is
491 # >= 1 by the cap, so a site is always truthy and None never is
492 if not (is_wholly_suffix(second, state.lexicon, state.policy)
493 and _peel_site(state, state.segments[0], tails)):
494 runs = state.segments[:2]
495 site = _peel_site(state, [j for seg in runs for j in seg], tails)
496 if site is None:
497 return state
498 i, offset = site
499 # The tail carries a tag because this stage MANUFACTURED it. The
500 # segmenter's neighbour test below needs to tell it from a token
501 # somebody wrote, and no vocabulary question can: the two spellings
502 # put the same word in the same place, and only the provenance
503 # differs.
504 return _split(state, i, (offset,), None, tail_tag=_PEELED_TAG)
505
506
507def _split_surname_site(state: ParseState) -> ParseState:
508 """The stage's other split: the first activated-script token of the
509 name part is matched longest-first against Lexicon.surnames, and a
510 hit splits it in two; where the vocabulary declines, an optional
511 Parser(segmenter=...) is consulted instead.
512
513 A sibling of _peel_honorific_tail rather than a continuation of it.
514 The two answer different questions -- this one asks where a name
515 divides into surname and given, the peel asks whether a token ends
516 in a word that can never end a name -- which is why they carry
517 different gates: the FAMILY comma and the 间隔号 gate this half
518 alone (#312), and segment_scripts below gates it alone too. That
519 is also why the stage entry below interleaves gates with its two
520 calls rather than running one cascade."""
521 scripts = state.policy.segment_scripts
522 # an empty VOCABULARY deliberately does not bail here -- see below
523 if not scripts:
524 return state
525 # segments[0] is the NAME part under both remaining structures
526 # (everything, under NO_COMMA); later segments are suffixes. Its
527 # members are main-stream token indices by construction, so
528 # extracted nickname/maiden content is unreachable from here. For
529 # this site that is merely tidy -- the first script-written token
530 # is the same either way. It is the PEEL above that reads this run
531 # load-bearingly, and in the two structures that reach here it
532 # reads exactly this one, only backwards; the argument lives at
533 # that scan.
534 i = next((i for i in state.segments[0]
535 if effective_script(state.tokens[i].text) in scripts), None)
536 # A post-nominal in the surname's own position is not a site to
537 # skip past but an answer: a surname LEADS, so if the leading
538 # script-written token is an honorific there is no surname here to
539 # find. Scanning ON would reach the given name, which is exactly
540 # what the first-token rule exists to prevent -- 지 is a listed
541 # surname, so "양 지훈" (양 is a surname AND a shipped honorific)
542 # would have its own given name split in half. Declining also
543 # covers the token the peel above manufactures, which is the first
544 # and only script-written one in "Anderson선생님".
545 if i is None or _is_post_nominal(state, i):
546 return state
547 token = state.tokens[i]
548 text = token.text
549 surnames = state.lexicon.surnames
550 # The vocabulary match reads the token's CORE (#323). Without it
551 # the stop WAS the remainder: '김.' matched its own head and became
552 # 김 + '.'; the stop rides with the remainder instead, so '김민준.'
553 # divides as 김 + '민준.'. A leading stop never reaches THIS site --
554 # the classification fold in front rstrips too
555 # (_vocab._normalized_for_script) and hides such a token before it
556 # arrives -- while the peel above has no such gate in front of it
557 # and its own rstrip is what does the work there.
558 core = text.rstrip(FULL_STOPS)
559 # A token that IS a surname never splits: a bare "남궁" must not
560 # become 남 + 궁 just because the single-syllable surname also
561 # matches -- there is nothing to split off, and a lone token's
562 # role is the order resolution's call, stop or no stop.
563 if core in surnames:
564 return state
565 # Longest-first (compound-before-single falls out of it), capped
566 # so the remainder is never empty. Direct membership, no
567 # _normalize: the script gate admits only CJK text, and a CJK
568 # entry's stored form is its NFC composition with no case or
569 # edge-stop change (#322) -- so an entry AUTHORED in NFD is
570 # composed on the way in, raw NFD input matches nothing here, and
571 # the name goes unsplit, which is the no-split rules.md#W1 already
572 # accepts for NFD text. An empty vocabulary skips the match rather
573 # than bailing the stage (_longest_entry's max() has nothing to
574 # take): a surname-less lexicon declines every token, which is
575 # exactly the condition the segmenter is consulted on, so an early
576 # bail would make a configured segmenter silently inert under
577 # Lexicon.empty() -- the JA pack's own shape.
578 matches: list[int] = []
579 if surnames:
580 cap = min(_longest_entry(surnames), len(core) - 1)
581 matches = [length for length in range(cap, 0, -1)
582 if core[:length] in surnames]
583 if matches:
584 take = matches[0]
585 detail = None
586 if len(matches) > 1:
587 # more than one vocabulary-supported split: longest-first
588 # DECIDED a fork, and the deciding stage records it. A
589 # single-match split chose nothing, so it stays silent --
590 # a dictionary certainty and the statistical guess below
591 # are different epistemic states, and these two emission
592 # rules say so.
593 chosen = " + ".join(map(repr, _pieces(text, (take,))))
594 other = " + ".join(map(repr, _pieces(text, (matches[1],))))
595 detail = (f"{text!r} splits as {chosen} on the longest "
596 f"surname; {other} also reads")
597 return _split(state, i, (take,), detail)
598 if state.segmenter is None:
599 return state # the first activated-script token decides
600 # The segmenter's precondition, which the vocabulary has no twin of:
601 # it is asked where an UNDIVIDED name divides, so it may only be
602 # shown a token that is the whole name. Where the name part carries
603 # a second script-written token the writer already drew this
604 # stage's missing boundary -- "山田 太郎" is divided, and dividing
605 # its family again yields 山 + 田 + 太郎 (namedivider answers for
606 # any string, and scores a two-character one 1.0 by rule, so no
607 # confidence check would catch it). The neighbour counts whatever
608 # script it is written in, ACTIVATED or not -- effective_script is
609 # merely non-None -- because a katakana or hangul neighbour is a
610 # boundary its writer drew just as deliberately as a Han one: under
611 # the JA pack "山田太郎 マイケル" declines, though katakana is in no
612 # activation set. A Latin title or suffix is NOT such a boundary:
613 # it says nothing about where the CJK name splits, so "Dr 阿明日,
614 # Jr." still reaches the segmenter. Vocabulary keeps its own rule
615 # -- a listed surname is a certainty about that exact string,
616 # whoever else stands beside it.
617 # The one neighbour that does not count is the one this stage
618 # MANUFACTURED (#308): a glued 山田太郎様 has no writer-drawn
619 # boundary anywhere, so the 様 the peel just cut off cannot be read
620 # as one -- the precondition must see what the writer wrote, which
621 # was a single undivided token. A SPACED 様 does count, and the
622 # reason is weaker than "its writer drew that boundary and chose
623 # to write 山田太郎 as a unit": in "山田太郎 様" the unit the writer
624 # drew is the WHOLE NAME, honorific and all, so that story is false
625 # for the very input it describes. What the code relies on is that
626 # by POSITION a spaced honorific is indistinguishable from a spaced
627 # name element, so the test conservatively counts it. Measured, the
628 # trade is worth it: counting them keeps 佐藤 氏, 田中 様, 鈴木 先生
629 # and 中村 教授 whole -- all four divide bare under the JA pack (佐
630 # + 藤, 田 + 中, 鈴 + 木, 中 + 村) -- and costs the single division
631 # 山田太郎 様. Four real surnames against one.
632 # Provenance, not vocabulary: the two spellings put the same word
633 # in the same place, and asking the suffix set instead cannot
634 # separate them.
635 if any(j != i and effective_script(state.tokens[j].text) is not None
636 and _PEELED_TAG not in state.tokens[j].tags
637 for j in state.segments[0]):
638 return state
639 # No try/except around the call: rules.md#A1's Accepted clause
640 # ("a user-supplied segmenter's own error propagates"). The two
641 # checks below are that same doctrine, curated,
642 # and they are where the line this module draws is easiest to state:
643 # a PROTOCOL VIOLATION BY THE SEGMENTER AUTHOR RAISES, while an
644 # ADAPTER'S DEFENSE AGAINST ITS LIBRARY DECLINES. Both checks here
645 # are the first kind -- a wrong answer TYPE and an answer indexing
646 # past the token it was handed are stage-detectable bugs in
647 # user-supplied code, inside the declared totality exception, so
648 # they get the same treatment the callable's own exceptions get.
649 # locales/ja.py is the second kind: its repertoire, length,
650 # reconstruction and score guards all return None, because what
651 # they defend against is namedivider answering a question nobody
652 # asked it, which is a fact about the CONTENT, not a broken
653 # protocol. Bounded like every message here: the type's NAME, never
654 # its contents.
655 # The CORE, not the raw token (#323): handed the raw token, a
656 # segmenter answering len-1 would make the stop a piece of its own
657 # ('田中太郎' + '。'), where against the core the trailing stops
658 # ride with the last piece -- '山田太郎.' answered at 2 divides as
659 # 山田 + '太郎.'.
660 answer = state.segmenter(core)
661 if answer is not None and not isinstance(answer, Segmentation):
662 # a duck-typed answer carrying a .splits of its own would
663 # otherwise wander into the split path and surface as a
664 # ValueError naming Token, pointing the reader at nameparser's
665 # insides instead of at their segmenter
666 raise TypeError(
667 f"segmenter must return Segmentation or None, got "
668 f"{type(answer).__name__}")
669 if answer is None or not answer.splits:
670 return state # declined, or confidently one token
671 # splits[-1] is the max -- Segmentation enforced ascending. The
672 # upper bound is the half Segmentation cannot check, since it never
673 # sees the text; an offset at or past the end would make an empty
674 # piece. Declining silently here (as this did before the review)
675 # made an off-by-one segmenter undebuggable: every answer it gave
676 # vanished, and the parse merely looked unsegmented.
677 # The bound is the CORE's length, the string the segmenter was
678 # actually handed: an offset it could not have derived from its own
679 # input is the same author bug whether or not `text` is longer.
680 if answer.splits[-1] >= len(core):
681 raise ValueError(
682 f"segmenter returned splits beyond the token: last offset "
683 f"{answer.splits[-1]}, the segmenter was given "
684 f"{len(core)} characters")
685 conf = answer.confidence
686 detail = None
687 if conf is not None and conf < _SEGMENTER_CONFIDENCE_FLOOR:
688 reading = " + ".join(map(repr, _pieces(text, answer.splits)))
689 detail = (f"{text!r} splits as {reading} on a segmenter answer "
690 f"scoring {conf:.2f}, under the "
691 f"{_SEGMENTER_CONFIDENCE_FLOOR} confidence floor")
692 return _split(state, i, answer.splits, detail)
693
694
695# rules.md#W1: "an undivided word in the family position of a name
696# written in an activated script divides after a recognized surname,
697# the longest recognized surname first; where the vocabulary
698# recognizes nothing, an optional segmenter may divide instead"
699# rules.md#W3: "under a family comma the pre-comma text is the
700# family by declaration and never divides, and the post-comma side
701# is given text with no family to find" (history: decisions.md#W3)
702def script_segment(state: ParseState) -> ParseState:
703 if state.original.isascii():
704 # spans index the original exactly (the anti-#100 invariant),
705 # so an ASCII original has only ASCII tokens: nothing here is
706 # in any script's ranges. It also short-circuits the PEEL,
707 # which has no script gate of its own -- so a caller-configured
708 # ASCII tail never fires, and one non-ASCII character anywhere
709 # in the name switches it on. Correct for the CJK vocabulary
710 # that ships, stated because honorific_tails is public: see
711 # that field's own note. Latin orthography SPACES its
712 # post-nominals ("John Smith PhD"), so the glued position this
713 # peel exists for is a CJK one to begin with. What Latin does
714 # glue is PREnominal and period-joined (Mr.Smith, Lt.Gov.),
715 # which _vocab.period_joined_vocab classifies as one token
716 # rather than splitting -- a different mechanism, and no peel
717 # site either way. A glued Latin POST-nominal is spelled the
718 # same way, so it reaches that same mechanism rather than
719 # nothing: period_joined_vocab reads "Smith.Jr." as a title
720 # ('jr' is title vocabulary as well as suffix vocabulary), and
721 # where position allows, that wins -- "Smith.Jr. Anderson"
722 # gives title "Smith.Jr.", family "Anderson". Whatever it
723 # decides, this bail is what settles the question here: it
724 # returns above all of it, so no ASCII input reaches the peel.
725 return state
726 if not state.segments:
727 return state
728 # #312: the peel runs in front of both gates below, because both
729 # answer where a name divides into surname and given, and the peel
730 # does not ask that. It asks whether a token ends in a word that
731 # can never end a name, and a comma or a dot elsewhere in the
732 # string does not change the answer. Placing it above the block
733 # also keeps a future segmentation gate from silently capturing
734 # it: a new gate lands with its siblings, below.
735 state = _peel_honorific_tail(state)
736 if state.structure is Structure.FAMILY_COMMA:
737 return state # the comma already drew the SURNAME boundary
738 if state.interpunct_offsets:
739 # #298: a 间隔号-divided name is a transcription -- its pieces
740 # are syllable groups, not surname+given, so neither the
741 # vocabulary nor the segmenter applies (codepoint-scoped: the
742 # nakaguro records nothing and gates nothing, spec decision 5).
743 # State-global like the FAMILY_COMMA gate above: a marker
744 # anywhere in the name reads the WHOLE name as a transcription
745 # listing, so even an un-dotted hangul token beside a dotted
746 # one stays whole. Scoped to the surname split since #312: an
747 # honorific glued to a transcription is still an honorific.
748 return state
749 return _split_surname_site(state)