1"""Internal pipeline state: WorkToken and ParseState.
2
3WorkTokens are pipeline-internal (no validation -- the tokenizer is the
4only producer) and are addressed BY INDEX in every stage: pieces and
5segments are runs of token indices, never joined strings, so value-based
6lookup (v1's #100 family) is structurally impossible.
7
8Layering: imports _types, _lexicon, _policy only (enforced by
9tests/v2/test_layering.py).
10"""
11from __future__ import annotations
12
13import bisect
14import dataclasses
15from collections.abc import Iterable, Mapping, Sequence
16from dataclasses import dataclass
17from enum import Enum, auto
18from types import MappingProxyType
19from typing import TYPE_CHECKING, TypeVar
20
21from nameparser._lexicon import Lexicon
22from nameparser._policy import Policy
23from nameparser._types import AmbiguityKind, Segmenter, Span
24# re-exported for _vocab, which layering keeps off _types
25from nameparser._types import ReadingKind as ReadingKind
26from nameparser._types import ReadingSource as ReadingSource
27from nameparser._types import Role as Role
28from nameparser._types import SHAPE_ACRONYM_TAG as SHAPE_ACRONYM_TAG
29from nameparser._types import SPLIT_CREDENTIAL_TAG as SPLIT_CREDENTIAL_TAG
30from nameparser._types import SPLIT_MEMBER_TAG as SPLIT_MEMBER_TAG
31
32if TYPE_CHECKING:
33 from nameparser._pipeline._pieces import TailRead
34
35
36# The comma characters (ASCII/Arabic/fullwidth, #265; Ethiopic and
37# Burmese, #1202; N'Ko, Mongolian, Manchu, Lisu, Vai and Bamum, #1202's
38# follow-up -- not the Armenian U+055D, which is no comma, decisions/C1).
39# Shared here so tokenize (separators/segmentation), extract
40# (close-quote boundaries, a listed particle's word end) and _vocab
41# cannot drift apart.
42COMMA_CHARS = frozenset({",", "\u060c", "\uff0c", "\u1363", "\u104a",
43 "\u07f8", "\u1802", "\u1808", "\ua4fe",
44 "\ua60d", "\ua6f5"})
45#: The same characters as one string, for str.strip: a word of a
46#: set-apart clause keeps the comma written against it (rules/N1), and
47#: every site reading that word without it strips this, so a clause
48#: reads alike whichever script's comma divides it (decisions/C1, #1202).
49COMMA_STRIP = "".join(sorted(COMMA_CHARS))
50
51
52def comma_bucket(start: int, comma_offsets: Sequence[int]) -> int:
53 """Which comma-delimited part of the name a token starting at
54 `start` falls in: the number of commas before it.
55
56 Shared here for the reason COMMA_CHARS is, and the sharing is
57 load-bearing in the same way. segment BUILDS the segments with this
58 (comma_offsets is sorted and no offset ever equals a token start,
59 so bisect_left counts the commas before the token); classify asks
60 it to decide whether two tokens could be in one segment, which is
61 half of what stops a maiden marker run from spanning a boundary no
62 segment holds. Two tokens agree here iff segment would put them in
63 one segment, and that is an identity rather than a resemblance
64 only while both sides ask this function.
65 """
66 return bisect.bisect_left(comma_offsets, start)
67
68@dataclass(frozen=True, slots=True)
69class WorkToken:
70 """One tokenized word. role stays None until assign; extracted
71 nickname/maiden tokens arrive with their role pre-set. text is
72 always the exact original slice (tokenize is the sole producer;
73 the anti-#100 invariant depends on it)."""
74
75 text: str
76 span: Span
77 tags: frozenset[str] = frozenset()
78 role: Role | None = None
79
80
81#: M4's two carve-outs, as the tags classify recorded them: a bound
82#: given-name word is vocabulary claiming the word as a given name,
83#: and `initial` is the initial reading. Neither is a predicate M4 owns.
84#: Shared here beside WorkToken.tags for the reason COMMA_CHARS is:
85#: assign's `_WORD_ALREADY_CLAIMED` is built from this pair, and the
86#: two stages must not drift (post_rules imports _assign, so _assign
87#: cannot reach the other way).
88_NEVER_FLIPPED = frozenset({"vocab:bound-given", "initial"})
89
90# SHAPE_ACRONYM_TAG is defined and explained in _types (its #: block
91# above the constant, not restated here -- defined there so _render
92# can read it too); re-exported here beside the membership half below
93# because the stages read both halves from one module.
94
95#: The MEMBERSHIP half of the same class: classify's tag for a token
96#: the ambiguous credential vocabulary claims, by listing
97#: (`Lexicon.suffix_acronyms_ambiguous`) or -- where a Policy switch
98#: admits the by-shape half -- beside `SHAPE_ACRONYM_TAG`. Beside that
99#: constant and for its reason: the string was spelled out at eight
100#: sites across four modules, each of them a place for a typo to pass
101#: silently, since a tag that is never written is simply a tag no
102#: reader ever finds.
103AMBIGUOUS_ACRONYM_TAG = "vocab:suffix-ambiguous"
104
105#: Beside `AMBIGUOUS_ACRONYM_TAG` on a suffix WORD that is also a name
106#: (`Lexicon.suffix_words_ambiguous`, rules/S11): the class is the
107#: same, and so is every site that weighs it, but the writing leans the
108#: other way -- lower case in a mixed-case name is the suffix, where an
109#: acronym's capitals are (`_vocab.ambiguous_lean`), but only on a
110#: word written `title_case_is_name` (`fils`, `kc`), which
111#: `TITLE_CASE_NAME_TAG` marks; any other leans nowhere (#1039).
112#: `AGNOMEN_TAG` rides beside both on an agnomen, for its reading's kind.
113AMBIGUOUS_WORD_TAG = "vocab:suffix-word-ambiguous"
114AGNOMEN_TAG = "vocab:agnomen"
115TITLE_CASE_NAME_TAG = "vocab:title-case-name"
116
117#: Beside "vocab:suffix-word" on a word whose shipped reading index
118#: kind is a generation (`jr`), a credential written as a word
119#: (`博士`) or an honorific (`さん`, #326), which the compiled sets do
120#: not record (rules/R8, 3.0's 4b):
121#: written by classify from the word as it reads it, a delimiter glued
122#: on removed (`Jr.;`, rules/C10), and read by `_vocab.suffix_kind`.
123GENERATION_WORD_TAG = "vocab:generation-word"
124CREDENTIAL_WORD_TAG = "vocab:credential-word"
125HONORIFIC_WORD_TAG = "vocab:honorific-word"
126
127#: Beside "particle" on a word the shipped reading index holds as a
128#: patronymic particle (`bin`, `ibn`, `bat`), which the compiled sets
129#: do not record (rules/R9, 3.0's 4c): written by classify, read by
130#: assemble for `Token.reading` and by assign for the reading a report
131#: names, and dropped from the published token like the kind tags above.
132PATRONYMIC_TAG = "vocab:patronymic"
133
134#: On each word of a particle chain opening a family-first name past
135#: the reach of rules/P1's fold (#828), written by post_rules whether or
136#: not the fold cuts the chain there, so assemble can tell where a
137#: report's other reading would land; dropped from the published token
138#: like the kind tags above.
139PAST_REACH_TAG = "chain:past-reach"
140
141
142def reported_suffix_fork(ambiguities: Sequence[PendingAmbiguity],
143 indices: Iterable[int],
144 skip: PendingAmbiguity | None = None) -> bool:
145 """rules/P1's question about a chain opening a family-first name
146 that the fold would cut (#828): whether a suffix-or-name report (the
147 trailing read's, or group's on a word the chain took) names a word
148 of `indices`, the words past the fold's reach. Such a word was read
149 against the whole chain, so the chain stands. Asked by the fold in post_rules and, with `skip`
150 the report whose other reading it names, by assemble."""
151 past = set(indices)
152 return any(a is not skip and a.kind is AmbiguityKind.SUFFIX_OR_NAME
153 and not past.isdisjoint(a.indices) for a in ambiguities)
154
155
156#: Beside FOLDED_TAG on the particles rules/P6 attaches to the family
157#: written before them, at either of its sites, so rules/R9 can tell
158#: the attachment from O3's middle fold, which writes FOLDED_TAG alone;
159#: dropped from the published token like the kind tags above.
160ATTACHED_TAG = "vocab:attached"
161
162#: On each word of the second name unit rules/O6 reads a family as,
163#: written where O6 takes its word, and dropped from the published
164#: token like the kind tags above: `Reading.second_surname` carries it
165#: out (rules/R10, 3.0's 4d).
166SECOND_SURNAME_TAG = "vocab:second-surname"
167
168#: Its twin on each word of the FIRST name unit: written where O6 reads
169#: two, on the family O6 found under a family-first order (a particle
170#: P6 attached included) and on the unit it took under the default one,
171#: and where O6 marked none, by O3 on the family it folds middles into,
172#: so a folded middle is in no unit; carried out on
173#: `Reading.first_surname` (rules/R11, #1022).
174FIRST_SURNAME_TAG = "vocab:first-surname"
175
176#: On the second of two listed single surnames that script_segment
177#: divides a Han name's family into (rules/W5, #845), so post_rules
178#: reads it as the family's second unit and marks both units with the
179#: two tags above; dropped from the published token like them.
180SURNAME_PAIR_TAG = "vocab:surname-pair"
181
182#: On a word of `Lexicon.titles_ambiguous` whichever way classify read
183#: it (rules/H9): read as a title it carries "vocab:title" too, read
184#: out of the title it carries this alone, which is how assign finds
185#: the fork to report where the word opens the name.
186TITLE_AMBIGUOUS_TAG = "vocab:title-ambiguous"
187
188#: Written by classify on every occurrence of a word of
189#: `Lexicon.titles_ambiguous` whose title reading is written short
190#: (`Lexicon.title_abbreviations`, `maj`), whichever way the phrase
191#: test read it; read by classify's CHAIN_TITLE_TAG below, rules/H5's
192#: trailing chain taking the word where it wears the abbreviation
193#: shape, and by group's P5 decline. Dropped from the published token like
194#: the kind tags above; the published token keeps TITLE_AMBIGUOUS_TAG.
195TRAILING_TITLE_TAG = "vocab:trailing-title"
196
197#: Written by classify on a word of `Lexicon.titles` (not
198#: `titles_ambiguous`), listed whole, whose letters a period joins, a
199#: hyphen beside it or not (`_vocab._JOINED_WORD`: 'Dott.ssa',
200#: 'Priv.-Doz'), and on a word with interior periods every chunk of
201#: which is a title ('Lt.Gov', #1131), never on one read as a title
202#: through some chunk only ('Dr.PhD', 'St.John') nor on one a hyphen
203#: alone joins ('Co-Founder'); read by classify's CHAIN_TITLE_TAG
204#: below, rules/H5's trailing chain taking it without a final period
205#: (#1104). Dropped from the published token like the kind tags above.
206JOINED_TITLE_TAG = "vocab:joined-title"
207
208#: Written by classify, after rules/H9's phrase test has settled which
209#: words keep "vocab:title", on every word rules/H5's trailing chain
210#: takes: a word carrying JOINED_TITLE_TAG, or one wearing the trailing
211#: abbreviation shape (`_vocab._TRAILING_TITLE_SHAPE`) that carries
212#: "vocab:title" or TRAILING_TITLE_TAG. The one statement of that test,
213#: read as a set membership by the chain (`_pieces.trailing_titles`),
214#: by its one-word form (`_pieces.is_trailing_title_word`, which the
215#: maiden take and P13's walk back ask) and by `_pieces._weighed`
216#: (#1171). Whether the word stands where the chain reaches is the
217#: reader's question. Dropped from the published token like the kind
218#: tags above.
219CHAIN_TITLE_TAG = "vocab:chain-title"
220
221#: On a run of two or more single letters joined by full stops whose
222#: spelling without them is a title written `dotted`
223#: (`Lexicon.dotted_titles`: 'U.S.', 'P.D.', #490), a word the
224#: vocabulary does not list as a title as written: rules/H10's two
225#: title runs at the head of a part, the given part's
226#: (`_pieces.leading_titles`) and H6's before a family comma (assign),
227#: take it as a title only where a title the run keeps stands behind it,
228#: so 'U.S. Grant' stays a name; classify's H9 fold and assemble's
229#: reading source read it too, and C4's count (`_vocab.name_word_count`)
230#: asks the same question without it.
231DOTTED_TITLE_TAG = "vocab:dotted-title"
232
233#: On a word of `Lexicon.given_name_abbreviations`, a given name written
234#: short ('Chas.', 'Ma.'): rules/H2's shape test declines a word
235#: carrying it, so the given reading outranks the shape-derived title
236#: (`_pieces.is_leading_title`, `_vocab.is_title_shaped`).
237GIVEN_ABBREVIATION_TAG = "vocab:given-abbreviation"
238
239#: On a word of `Lexicon.shared_names`, a name a community gives every
240#: member (Sikh 'Kaur', 'Singh'): rules/O6 and O7 decline a join that
241#: takes or completes one, and P10 completes none
242#: (`_post_rules._take_second_family_name`, `_take_second_given_name`,
243#: `_given_phrase_middle`).
244#: Dropped from the published token like the kind tags above (#704).
245SHARED_NAME_TAG = "vocab:shared-name"
246
247#: On a word of `Lexicon.particle_given_names`, a given name written
248#: behind a particle as the second of a compound given name ('del
249#: Carmen'): rules/P10 reads the particle run and the word as a
250#: middle name behind a one-word given name, and O6 leaves them there.
251#: Dropped from the published token like the kind tags above (#704).
252PARTICLE_GIVEN_TAG = "vocab:particle-given"
253
254#: On a bound given name whose word and the word after it are a phrase
255#: of `Lexicon.bound_phrases` ('عبد' before 'الله'), which rules/P12
256#: joins past the first name word (`_group._join_bound_phrases`), and
257#: group leaves it only on a pair it joined. Dropped
258#: from the published token like the kind tags above (#694).
259BOUND_PHRASE_TAG = "vocab:bound-phrase"
260
261#: EITHER way a token joins the ambiguous credential class -- the
262#: vocabulary's claim and the writing's. The two emitters that report
263#: a fork the peel called and declined ask exactly this: `_group`'s
264#: prefix chain (a listed member the case lean read as a name, 'John
265#: van der Berg Ma'; a by-shape member the count left standing,
266#: 'Freiherr von Berg X.Y.I.') and `_assign`'s family-comma slot. One
267#: constant beside the two above and for their reason -- the pair was
268#: spelled two ways, a frozenset here and an `or` of two `in` tests
269#: there -- and a frozenset so each test is one `isdisjoint`, a C call
270#: with no Python frame, on a branch every chained name reaches.
271_AMBIGUOUS_CREDENTIAL_TAGS = frozenset(
272 {AMBIGUOUS_ACRONYM_TAG, SHAPE_ACRONYM_TAG})
273
274# SPLIT_CREDENTIAL_TAG and SPLIT_MEMBER_TAG are defined and explained in
275# _types beside SHAPE_ACRONYM_TAG, case repair reading them too.
276
277#: The roles that make a piece a name word (rules/P6's walk), and
278#: the roles of a word not yet read as one: a suffix, or nothing yet
279#: -- what `_pieces.particle_tail` reads, asked by assign's given-part
280#: walk when it reaches the credential run, whose words are unread.
281NAME_ROLES = (Role.GIVEN, Role.MIDDLE, Role.FAMILY)
282SUFFIX_OR_UNREAD = (Role.SUFFIX, None)
283
284
285class Structure(Enum):
286 """The comma structure: segment writes NO_COMMA or FAMILY_COMMA,
287 and group's head decides a comma form once classify has tagged the
288 words, which may make it SUFFIX_COMMA (#613, `_comma.decide`)."""
289
290 NO_COMMA = auto()
291 FAMILY_COMMA = auto() # "Family, Given ..." (v1 lastname-comma)
292 SUFFIX_COMMA = auto() # "Given Family, Suffix ..."
293
294
295@dataclass(frozen=True, slots=True)
296# rules/A1: "the parse completes on the best reading and carries an
297# ambiguity report naming the doubt, as parsing never fails on any
298# input" -- its Then clause: this is the report's mid-pipeline record,
299# by token index (decisions/A1)
300class PendingAmbiguity:
301 """An ambiguity recorded mid-pipeline by token INDEX; assemble
302 materializes real Ambiguity objects over the final tokens.
303
304 ``origin`` is for the one stage that runs BEFORE tokens exist:
305 extract_delimited knows only a character offset, so it records that
306 and tokenize resolves it to the containing token's index. Stages
307 after tokenize set ``indices`` directly and leave ``origin`` None.
308
309 ``field_tail`` is for a report that names the field its word was
310 read into. post_rules may still move the word (H1's move behind a
311 title, P1's family-first fold, P6's attachment, and under opt-in
312 policies the patronymic rotations, O6's two family names and
313 `middle_as_family`'s fold),
314 so the emitter
315 writes ``detail`` up to the field and the rest of its sentence
316 here -- ``""`` where the field ends the sentence, which is why
317 assemble tests ``is not None`` -- and assemble joins them around
318 the FIELD_READING of the first referent's final role, the one
319 place that knows it (#626). Until then ``detail`` is a fragment.
320 """
321
322 kind: AmbiguityKind
323 detail: str
324 indices: tuple[int, ...] = ()
325 origin: int | None = None
326 field_tail: str | None = None
327 alternative: PendingReading | None = None
328
329
330class Deferred(Enum):
331 """A PendingReading field its emitter cannot name yet."""
332
333 #: O5's: a lone name word read as one of the two primary fields
334 #: would be read as the other, named from its FINAL role by
335 #: assemble, since H1 or a rotation may still move it after the
336 #: emitter (#626's reason for ``field_tail``).
337 OTHER_PRIMARY = auto()
338 #: classify's connective: the letter read as a connective would
339 #: join the name words on either side into one, whose field assign
340 #: names once it has positions -- the positional read's, where the
341 #: join would merge two pieces, and the letter's own piece's
342 #: (`Beside`), where both words are already in it.
343 JOINED = auto()
344 #: group's particle chain: the particle it chained would stand as a
345 #: name word of its own in front of the piece, named by assign.
346 UNCHAINED = auto()
347
348
349@dataclass(frozen=True, slots=True)
350class Beside:
351 """A PendingReading field named from another token's: the reading
352 lands where the token at `index` lands, named by assemble from that
353 token's final role -- a word the name piece in front would take in
354 (a particle chain, which runs to the end of the name words, or a
355 bound given name), or a connective whose join stays inside its own
356 piece. Where P6 then attached that token to the family
357 (`FOLDED_TAG`), which it would not have done with a word behind
358 it, `unfolded`, the field the word held when the report was made,
359 stands instead. Where the piece in front is a chain, `last` is its
360 last word: rules/P10 reads a chain's opening phrase as a middle name
361 and leaves its last word the family, where the word would join, so
362 a `last` still in the family names the field (#704)."""
363
364 index: int
365 unfolded: Role | None = None
366 last: int | None = None
367
368
369@dataclass(frozen=True, slots=True)
370class PendingReading:
371 """The reading a report's decision did not take, as its emitter
372 knows it; assemble turns it into the public Reading.
373
374 ``role`` is the field the first referent would hold under that
375 reading, with every other word of the name read as the parse read
376 it, a `Deferred` form for a stage that runs before the field is
377 known, or `Beside` for a field another token's final role names.
378 A JOINED or UNCHAINED form assign leaves unresolved -- a referent
379 that ends outside every name position -- surfaces as no
380 alternative rather than a guess. Assemble also folds a MIDDLE into
381 the family under ``middle_as_family``, the one later rule that
382 moves a field whatever word stands in it."""
383
384 kind: ReadingKind
385 source: ReadingSource
386 role: Role | Deferred | Beside
387
388
389#: How a report names the field a word was read into, by final role.
390FIELD_READING: Mapping[Role, str] = MappingProxyType({
391 Role.TITLE: "a title", Role.GIVEN: "a given name",
392 Role.MIDDLE: "a middle name", Role.FAMILY: "a family name",
393 Role.SUFFIX: "a suffix", Role.NICKNAME: "a nickname",
394 Role.MAIDEN: "a maiden name"})
395# every role a referent can hold, or a new one is a KeyError at the
396# first parse whose report lands in it
397assert FIELD_READING.keys() == set(Role)
398
399
400@dataclass(frozen=True, slots=True)
401class ParseState:
402 """Carried through the stage fold. Frozen; stages return copies via
403 copy_with. Fields are filled progressively:
404 extract_delimited -> extracted/masked; tokenize -> tokens (span-
405 sorted)/comma_offsets/interpunct_offsets (the 间隔号 and fullwidth-stop offsets the
406 order and segmentation decisions consult, #298; the nakaguro
407 separators record NOTHING); segment -> segments/structure/one_case
408 (lazily, only where C2's flag asks for it -- #289/#516, #613);
409 script_segment -> tokens and segments again (the one stage that
410 changes the token COUNT: an unspaced CJK token, or a word glued at
411 its full stop before any comma (rules/T8), splits into n+1 pieces,
412 still as sub-slices of the original, and every later index in the
413 segment runs shifts by n); classify -> token tags AND
414 one_case; group -> structure (its head decides a comma form, #613)
415 AND the SUFFIX/TITLE roles of a postnominal part after the comma,
416 whose segment it empties so no join reaches it,
417 then pieces/piece_tags/dropped AND maiden token roles, plus the
418 SUFFIX/TITLE roles of the run a maiden take gives up (#601), AND
419 tail_reads, S2's trailing run read once before its joins (#614);
420 assign -> the remaining token roles AND `order`, the effective
421 order it read them under, AND the folded-middle and attached tags
422 on the particles P6's attachment joins after a family comma (#613);
423 post_rules -> roles again (the TITLE and SUFFIX roles of a clause
424 S1 sets apart, each word's comma trimmed off its text and span,
425 #792; a delimited member S13 settles becomes the suffix, #810),
426 the folded-middle tag on what O3 folds and the
427 no-comma attachment joins, with the attached tag on the latter,
428 the second-surname tag on the words of the family's second unit
429 where O6 reads two or W5 reads a Han surname pair (rules/R10), the
430 first-surname tag on the words of its first unit (rules/R11), and the ambiguity P6's no-comma
431 attachment
432 reports (#467) and SUFFIX_OR_NICKNAME on a member it leaves a
433 nickname.
434 Ambiguities are recorded by every stage that DECIDES one --
435 extract (resolved to a token index by tokenize), segment,
436 script_segment, classify, group, assign, and post_rules -- since a
437 fork whose branches are taken in different stages needs an emitter
438 in each. Post-group, segments may retain indices of dropped tokens
439 -- assign iterates pieces, never segments. This ownership map is
440 pinned by tests/v2/pipeline/test_state.py.
441
442 segmenter belongs to no stage: like original/lexicon/policy it is
443 passed in at construction by Parser.parse and only ever READ (by
444 script_segment, for a token the vocabulary declined)."""
445
446 original: str
447 lexicon: Lexicon
448 policy: Policy
449 #: The optional Parser(segmenter=...) hook; None = not configured.
450 segmenter: Segmenter | None = None
451 extracted: tuple[tuple[Role, Span], ...] = ()
452 masked: tuple[Span, ...] = ()
453 tokens: tuple[WorkToken, ...] = ()
454 comma_offsets: tuple[int, ...] = ()
455 interpunct_offsets: tuple[int, ...] = ()
456 segments: tuple[tuple[int, ...], ...] = ()
457 structure: Structure = Structure.NO_COMMA
458 # pieces[s][p] = run of token indices: piece p of segment s.
459 # piece_tags[s][p] = derived flags for that piece ("title", "prefix",
460 # "suffix", "conjunction") set by group's joins.
461 pieces: tuple[tuple[tuple[int, ...], ...], ...] = ()
462 piece_tags: tuple[tuple[frozenset[str], ...], ...] = ()
463 dropped: tuple[int, ...] = () # structural tokens (maiden markers)
464 #: rules/S2's trailing run per segment, as group read it ONCE
465 #: after P3's joins and before the particle chain (#614), for
466 #: assign to place and report
467 #: rather than peel again; None where group read none -- a family
468 #: comma's parts, a part past a comma, a segment of fewer than
469 #: three pieces, a run with a name piece inside it -- and assign
470 #: reads those for itself, as it does a name group's joins left
471 #: without a name word. Aligned with `pieces`.
472 tail_reads: tuple[TailRead | None, ...] = ()
473 #: The order `assign` actually READ the name under -- name_order,
474 #: or the script_orders entry that overrode it. None wherever no
475 #: positional read happened: after a family comma (which fixes the
476 #: family, so assign consults no order at all), and on every early
477 #: return in `_assign_main`, where a segment holds no name piece
478 #: to position. Recorded rather than recomputed downstream,
479 #: because the two can differ and a post_rules rule keyed on
480 #: `policy.name_order` would then disagree with the roles assign
481 #: already wrote (#395). Reaching that divergence needs a custom
482 #: lexicon -- every shipped particle is Latin, and Latin has no
483 #: script_orders entry -- which is why the test for it builds its
484 #: own (test_post_rules.py).
485 order: tuple[Role, Role, Role] | None = None
486 #: Whether the name's OWN words are written wholly in one case --
487 #: all upper or all lower alike -- and so carry no case EVIDENCE
488 #: about any word in them (rules/P3's own-words span,
489 #: _pieces.own_words). None means NOT ASKED YET: the fact is
490 #: computed by whichever of segment and classify needs it first,
491 #: segment only when C2's flag on a part past the second comma asks
492 #: for it, so a reader between the two stages sees None and must
493 #: not guess -- script_segment asks for itself there (#613).
494 #: Recorded rather than recomputed, the way `order` above is: the
495 #: trailing suffix slot, the post-comma slot, the tail-segment
496 #: reading, the prefix chain's own tail measure (_group) and the
497 #: glued-honorific peel's decline of a post-comma run
498 #: (_script_segment) all consult it and must not disagree
499 #: (#289/#516, decisions/S6). Five stages read it in all --
500 #: segment, script_segment, classify, group and assign -- plus the
501 #: two predicate layers they read it through (_pieces, _vocab).
502 #: A fact segment records SURVIVES script_segment, the one stage
503 #: that changes the token count, and survives it unrecomputed
504 #: because splitting a token cannot change the answer: the verdict
505 #: is `is_one_case` over the name's own words JOINED, and a split
506 #: only moves a space into a string whose upper/lower comparison
507 #: ignores spaces entirely -- '김민준씨' and '김민준 씨' fold alike.
508 #: So the field is carried through rather than invalidated, and
509 #: script_segment reads it where segment recorded it (its suffix-run
510 #: predicate takes the lean), asking for itself only where segment
511 #: did not, without recording the answer (`_one_case`).
512 one_case: bool | None = None
513 ambiguities: tuple[PendingAmbiguity, ...] = ()
514
515
516def _copy_refusals(cls: type) -> list[str]:
517 """Why a field copy of `cls` might not build what
518 `dataclasses.replace` builds, one reason per unmet condition.
519
520 `replace` calls `obj.__class__(...)`, and every hook that call can
521 run -- a metaclass `__call__`, a `__new__`, the `__init__` and any
522 `__setattr__` it goes through, a `__post_init__`, an `InitVar` it
523 demands for want of a default -- is one a field copy skips. These conditions are a
524 TRIPWIRE for a realistic edit to the pipeline's three classes, not
525 a proof against any class at all: a forged `__qualname__` on a
526 borrowed `__init__` combined with its own decoration, or a
527 `__class__` property, is out of scope, and `copy_with` copies
528 nothing but those three anyway."""
529 reasons = []
530 params = cls.__dict__.get("__dataclass_params__")
531 if params is None:
532 reasons.append("not decorated as a dataclass itself")
533 elif not params.frozen:
534 reasons.append("not frozen")
535 init = cls.__dict__.get("__init__")
536 # dataclasses compiles the __init__ it generates from a string and
537 # names it after the class; one written in a class body carries its
538 # source file, and one borrowed from another class keeps that
539 # class's name.
540 if (init is None or init.__code__.co_filename != "<string>"
541 or init.__qualname__ != f"{cls.__qualname__}.__init__"):
542 reasons.append("__init__ not generated for it")
543 init = None
544 fields = dataclasses.fields(cls)
545 if init is not None:
546 # an InitVar is an __init__ parameter that is not a field
547 code = init.__code__
548 taken = code.co_argcount + code.co_kwonlyargcount - 1
549 if taken != sum(f.init for f in fields):
550 reasons.append("__init__ does not take exactly its init fields")
551 if type(cls) is not type:
552 reasons.append("built by a metaclass")
553 if any("__new__" in vars(k) for k in cls.__mro__[:-1]):
554 reasons.append("defines __new__")
555 if hasattr(cls, "__post_init__"):
556 reasons.append("defines __post_init__")
557 if not all(f.init for f in fields):
558 reasons.append("has an init=False field")
559 return reasons
560
561
562def _copyable_fields(cls: type) -> tuple[str, ...]:
563 """The fields `copy_with` carries for `cls`, or TypeError naming
564 every condition of `_copy_refusals` it fails."""
565 reasons = _copy_refusals(cls)
566 if reasons:
567 raise TypeError(
568 f"copy_with cannot copy {cls.__name__}: it needs a frozen "
569 "dataclass whose generated __init__ only assigns its fields "
570 f"({'; '.join(reasons)})")
571 return tuple(f.name for f in dataclasses.fields(cls))
572
573
574#: Built once, at import: a pipeline class that stops qualifying fails
575#: here rather than in a parse, and `copy_with` copies nothing else.
576_COPY_FIELDS: Mapping[type, tuple[str, ...]] = MappingProxyType({
577 cls: _copyable_fields(cls)
578 for cls in (WorkToken, PendingAmbiguity, PendingReading, ParseState)})
579
580_T = TypeVar("_T")
581
582if TYPE_CHECKING:
583 # mypy's dataclass plugin checks replace's keywords against the
584 # class, which a `**changes: object` signature would not.
585 from dataclasses import replace as copy_with
586else:
587 def copy_with(obj: _T, /, **changes: object) -> _T:
588 """`dataclasses.replace` for the pipeline's own dataclasses,
589 without its per-call cost: a direct field copy, which for the
590 classes in `_COPY_FIELDS` builds the same object
591 (decisions/parse-cost has the measurement)."""
592 cls = type(obj)
593 names = _COPY_FIELDS.get(cls)
594 if names is None:
595 raise TypeError(
596 f"copy_with copies only the pipeline's own dataclasses, "
597 f"not {cls.__name__}")
598 new = object.__new__(cls)
599 for name in names:
600 value = changes.pop(name) if name in changes else getattr(obj, name)
601 object.__setattr__(new, name, value)
602 if changes:
603 raise TypeError(f"{cls.__name__} has no field named "
604 f"{', '.join(sorted(changes))}")
605 return new