1919from nameparser ._lexicon import _VOCAB_FIELDS
2020from nameparser ._pipeline import run
2121from nameparser ._pipeline ._state import ParseState
22+ from nameparser ._pipeline ._vocab import effective_script
2223from nameparser ._types import AmbiguityKind , Role
2324
2425from .conftest import differential_corpus
@@ -189,15 +190,29 @@ def test_particle_fork_is_never_double_reported(text: str) -> None:
189190# would be a dead entry that trips Lexicon's multi-word warning.
190191# The CJK tail (#271) is what lets a drawn `surnames` set activate
191192# script_segment at all: hangul is the script segmented by default, so
192- # "김"/"남궁" are what make the stage fire (see _names_using, which
193- # supplies the unspaced token to fire it ON), and the Han rows ride
194- # along for script_orders -- Han segmentation is opt-in via
195- # locales.ZH, which these policies do not draw.
193+ # "김"/"남궁"/"남" are what make the stage fire (see _names_using, which
194+ # supplies the unspaced token to fire it ON -- and, since the drawn
195+ # policy picks its own segment_scripts, only when that policy
196+ # activates hangul too). "남" is there for the stage's multi-match
197+ # FORK, which nothing else in this pool can reach: it is a proper
198+ # PREFIX of "남궁", so a lexicon drawn with both makes "남궁민준" match
199+ # twice, and longest-first then has to choose and report the reading
200+ # it passed over. Reachable is all it is -- both entries have to land
201+ # in the same drawn `surnames`, 0.8% of draws, so the fork fires
202+ # roughly once in 900 examples (42 over 36000 measured) and the
203+ # committed 250-example seed does not reach it at all; a randomized
204+ # run is what sees it. The Han rows ride along for script_orders, and
205+ # are not inert here either -- `_policies` draws segment_scripts
206+ # freely, so HAN is activated in 37.7% of drawn policies (measured
207+ # over 20000 draws), and an activated Han token STANDING EARLIER takes
208+ # the surname site: "欧阳 김민준" does not split, where "김민준 欧阳"
209+ # does. What these policies never draw is a locale PACK, which is the
210+ # only way shipped configuration turns Han segmentation on.
196211_VOCAB = st .sampled_from ([
197212 "van" , "de" , "la" , "bin" , "abdul" , "abu" , "dr" , "sir" , "prof" ,
198213 "md" , "jr" , "iii" , "esq" , "ma" , "do" , "and" , "y" , "née" , "geb" ,
199214 "a" , "b" , "ph.d" , "عبد" , "фон" , "μεγα" ,
200- "김" , "남궁" , "毛" , "欧阳" ,
215+ "김" , "남궁" , "남" , " 毛" , "欧阳" ,
201216])
202217
203218# given_name_titles is the one field matched as a space-joined run, so
@@ -301,13 +316,18 @@ def _policies(draw: st.DrawFn) -> Policy:
301316
302317
303318@st .composite
304- def _names_using (draw : st .DrawFn , lexicon : Lexicon ) -> str :
319+ def _names_using (draw : st .DrawFn , lexicon : Lexicon ,
320+ policy : Policy ) -> str :
305321 """Build the input out of the lexicon's OWN words.
306322
307323 Fuzzing configuration while feeding unrelated text tests almost
308324 nothing: a randomly generated string essentially never contains a
309325 randomly generated vocabulary entry, so every configured set would
310326 sit unused and the parse would take the same path every time.
327+
328+ Takes the POLICY as well as the lexicon because one of the shapes
329+ below is only reachable when the two agree -- see the segmentation
330+ note.
311331 """
312332 vocab = sorted ({w for name in _SET_FIELDS
313333 for w in getattr (lexicon , name )})
@@ -325,24 +345,83 @@ def _names_using(draw: st.DrawFn, lexicon: Lexicon) -> str:
325345 # mixed-script token the surname half correctly declines, and for
326346 # the peel it is the one shape that reaches an ASCII tail at all --
327347 # the stage bails on a wholly-ASCII original, so the non-Latin stem
328- # is what admits '민준jr'. Both fires observed under drawn lexicons
329- # are of exactly that shape.
330- # Neither derivation guarantees a fire on any given run: the piece
331- # is one of ~30 in the pool, so it competes with the bare vocabulary
332- # words, and a bare drawn surname standing earlier in the name takes
333- # the surname site before the unspaced token can. Instrument before
334- # concluding either half is exercised -- the counts above are what
335- # that costs to find out, and the two halves are NOT in the same
336- # state. Measured over this test's 250 examples: the peel fires
337- # twice, the surname half ZERO times -- currently inert. Structural,
338- # not luck: `w + "민준"` on a non-hangul surname is a MIXED-script
339- # token, whose effective_script is None, so it can never be an
340- # activated surname site at all; only a drawn HANGUL surname makes
341- # one, and across the run exactly one such token was sampled
342- # ('남궁민준'), into an example whose drawn policy had HANGUL out of
343- # segment_scripts. Deriving the token was still the right move --
344- # it took the half off structurally-unreachable -- but a fix worth
345- # having would derive it in the script the policy activated.
348+ # is what admits '민준jr'. Every peel fire observed under a drawn
349+ # lexicon is of exactly that shape -- the committed run's is
350+ # '민준de'.
351+ # What the forced insertion below is worth, in the one number that
352+ # cannot rot: instrument _split under this test's committed
353+ # derandomize=True seed -- peel and surname split are told apart by
354+ # its tail_tag argument -- and origin/master's version of this
355+ # strategy counts ZERO surname splits against two peels, while this
356+ # one counts 23 against one. That figure is reproducible by anyone
357+ # in one command and moves only when these strategies do.
358+ # Being in the POOL is not the same as being in the NAME, and for
359+ # the surname half that gap was the whole story. Two things must
360+ # coincide before the shape is legal at all -- a HANGUL surname
361+ # drawn AND a policy that activates hangul, together 5.5% of draws
362+ # over 20000 -- and the token then has to win a place in a 1-8
363+ # piece name against a pool of median size 23. Merely offered, it
364+ # reached the name a couple of times per 250. The shortfall was
365+ # sampling, never the stage.
366+ # What that buys is COVERAGE, not detection, and the two are worth
367+ # separating because the first is the easier to oversell. This
368+ # layer asserts totality and span-exactness only, so every
369+ # BEHAVIORAL mutant in the stage -- the site's last-token scan,
370+ # shortest-first, the whole-token guard, the activation gate, the
371+ # post-nominal decline, the prefix cap, the segment remap -- passes
372+ # here with the insertion and without it, and is killed by
373+ # tests/v2/pipeline/test_script_segment.py instead. The one defect
374+ # class this layer CAN catch is span arithmetic inside _split, and
375+ # the peel already reached that path. Writing `base + end` as
376+ # `base + end + 1` and giving each tree ten randomized runs of 250:
377+ # origin/master finds it in 8 runs of 10, at a median 1561 shrink
378+ # calls and 14.6 seconds; this tree finds it in 10 of 10, at 346
379+ # calls and 3.6 seconds, and shrinks to '김민준 김' where master
380+ # shrinks to the peel's '민준van'. st.integers shrinks the
381+ # insertion index toward 0, which walks the token into the position
382+ # likeliest to fire instead of away from it.
383+ # Alignment is still not a guarantee: with the token forced in the
384+ # split fires in about four aligned examples in five (350 of 450,
385+ # over 24 randomized runs of 250). Every decline is the stage
386+ # deciding, not waste. A FAMILY_COMMA opts the stage out whole -- a
387+ # ',' piece is in the pool. Otherwise an EARLIER script-written
388+ # token takes the surname site: a bare drawn surname ('김 김민준'
389+ # leaves 김민준 unsplit -- the whole-token guard), a drawn hangul
390+ # post-nominal (the leading post-nominal decline), or a Han token
391+ # under a policy that activated HAN. A LATIN word never takes it --
392+ # 'John 김민준' still splits, zero occurrences in 1082 aligned
393+ # examples -- which is why the insertion index is drawn rather than
394+ # pinned to 0.
395+ # Two earlier versions of this comment overstated this half from
396+ # small samples -- one calling it "structurally inert, not luck" on
397+ # a single derandomize=True run reporting zero, one putting the
398+ # conversion above at 100%. So: a derandomize=True run is one
399+ # sample and cannot disagree with itself, and every randomized
400+ # figure quoted here was taken under derandomize=False instead,
401+ # which is how to re-measure them. The structural claim is real but
402+ # narrower than it was made: `w + "민준"` on a NON-hangul surname is
403+ # a mixed-script token whose effective_script is None, so those
404+ # candidates can never be a site whatever the policy says. Only a
405+ # drawn hangul surname makes a usable one, which is what
406+ # `activatable` selects.
407+ # Two shapes the insertion costs, both small, neither zero. It is
408+ # unconditional once lexicon and policy align, so the stage's
409+ # i-is-None early return under a LIVE configuration fell from 1.3%
410+ # of examples to 0.1%: it survives only where a drawn quote pair
411+ # carries the token off into a nickname, leaving segments[0] with
412+ # nothing in an activated script ("prof ' Smith 남민준 '"). And the
413+ # inserted token can SUPPRESS a peel, the peel site being the last
414+ # non-post-nominal token and this token being no post-nominal:
415+ # '김민준씨 김민준' leaves 씨 glued where '김민준 김민준씨' peels
416+ # it.
417+ # The peel gets no forced piece of its own -- it is saturated by
418+ # case rows and stage tests, so a second one was not worth the
419+ # distribution shift -- and its count here is a lottery either way
420+ # (0-7 per randomized run of 250). On net the insertion nudges it
421+ # UP, by a route worth knowing: a hangul token makes the original
422+ # non-ASCII, which lifts the stage's ASCII bail off Latin tails
423+ # that are otherwise unreachable -- under honorific_tails={'a'},
424+ # 'John la' does not peel and '김민준 la' does.
346425 # sorted for the same reason `vocab` above is: frozenset iteration
347426 # order is not stable across runs, and an unsorted pool shifts
348427 # every index sampled_from draws -- which would defeat
@@ -353,7 +432,24 @@ def _names_using(draw: st.DrawFn, lexicon: Lexicon) -> str:
353432 # pool is never empty even for an empty lexicon
354433 pieces = st .sampled_from (
355434 vocab + unspaced + glued + ["John" , "Smith" , "Q." , "," , "(" , "'" ])
356- return " " .join (draw (st .lists (pieces , min_size = 1 , max_size = 8 )))
435+ drawn = draw (st .lists (pieces , min_size = 1 , max_size = 8 ))
436+ # The one shape the pool does not deliver RELIABLY -- it is in
437+ # there, it just loses the draw. Inserted at a drawn position
438+ # rather than the front: the stage takes the first ACTIVATED-script
439+ # token, not the first token, so a leading Latin word cannot hide
440+ # it -- and both placements are worth covering.
441+ activatable = sorted (
442+ w + "민준" for w in lexicon .surnames
443+ if effective_script (w + "민준" ) in policy .segment_scripts )
444+ if activatable :
445+ # rebuilt rather than list.insert()d: st.lists hands back a
446+ # fresh list today, so mutating it is safe today, and a
447+ # strategy that ever memoized one would make this a bug in the
448+ # fuzzer rather than in the code under test
449+ at = draw (st .integers (0 , len (drawn )))
450+ token = draw (st .sampled_from (activatable ))
451+ drawn = [* drawn [:at ], token , * drawn [at :]]
452+ return " " .join (drawn )
357453
358454
359455@given (_lexicons (), _policies (), st .data ())
@@ -363,7 +459,7 @@ def test_any_valid_config_still_parses_totally(
363459 # Building the parser is part of the contract: a Lexicon and Policy
364460 # that each constructed must also combine.
365461 parser = Parser (lexicon = lexicon , policy = policy )
366- text = data .draw (_names_using (lexicon ))
462+ text = data .draw (_names_using (lexicon , policy ))
367463 parsed = parser .parse (text ) # must not raise, ever
368464 # the anti-#100 invariant, under configuration rather than under
369465 # the default vocabulary: spans index the original exactly
0 commit comments