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
@@ -190,7 +191,9 @@ def test_particle_fork_is_never_double_reported(text: str) -> None:
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
192193# "김"/"남궁" 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+ # 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), and the Han rows ride
194197# along for script_orders -- Han segmentation is opt-in via
195198# locales.ZH, which these policies do not draw.
196199_VOCAB = st .sampled_from ([
@@ -301,13 +304,18 @@ def _policies(draw: st.DrawFn) -> Policy:
301304
302305
303306@st .composite
304- def _names_using (draw : st .DrawFn , lexicon : Lexicon ) -> str :
307+ def _names_using (draw : st .DrawFn , lexicon : Lexicon ,
308+ policy : Policy ) -> str :
305309 """Build the input out of the lexicon's OWN words.
306310
307311 Fuzzing configuration while feeding unrelated text tests almost
308312 nothing: a randomly generated string essentially never contains a
309313 randomly generated vocabulary entry, so every configured set would
310314 sit unused and the parse would take the same path every time.
315+
316+ Takes the POLICY as well as the lexicon because one of the shapes
317+ below is only reachable when the two agree -- see the segmentation
318+ note.
311319 """
312320 vocab = sorted ({w for name in _SET_FIELDS
313321 for w in getattr (lexicon , name )})
@@ -327,22 +335,40 @@ def _names_using(draw: st.DrawFn, lexicon: Lexicon) -> str:
327335 # the stage bails on a wholly-ASCII original, so the non-Latin stem
328336 # is what admits '민준jr'. Both fires observed under drawn lexicons
329337 # 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.
338+ # Being in the POOL is not the same as being in the NAME, and for
339+ # the surname half that gap was the whole story. Measured with the
340+ # token merely offered: over 250 examples the lexicon and policy
341+ # agree often enough (13-19 runs of 250) but the token is drawn into
342+ # the name only 0-4 times, because it is one entry among ~30 and a
343+ # name takes 1-8 pieces. Every time it IS drawn the split fires --
344+ # the conversion from sampled to fired is 100% -- so the shortfall
345+ # was sampling, never the stage. Hence the forced insertion below
346+ # rather than a wider pool.
347+ # An earlier version of this comment called the surname half
348+ # "structurally inert, not luck" on the strength of a single
349+ # derandomize=True run reporting zero. That run is one sample, and
350+ # repeating it cannot disagree with itself; randomized runs give
351+ # 0, 1, 1, 2, 2, 4. The structural part is real but narrower than
352+ # claimed: `w + "민준"` on a NON-hangul surname is a mixed-script
353+ # token whose effective_script is None, so those candidates can
354+ # never be a site whatever the policy says. Only a drawn hangul
355+ # surname makes a usable one, which is what `activatable` selects.
356+ # With the insertion the surname half measures 0, 2, 5, 6, 6, 7, 9,
357+ # 9, 10, 12, 15, 19 over twelve randomized runs of 250 -- roughly
358+ # eight times what it was, and NOT a guarantee. It cannot be one:
359+ # the insertion only fires when the drawn lexicon carries a hangul
360+ # surname AND the drawn policy activates hangul, which is about 14
361+ # draws in 250, and a run that draws few can still reach zero. What
362+ # changed is that alignment now converts to a fire every time
363+ # instead of one time in five.
364+ # The peel still rides on sampling alone (0-5 per run): it is
365+ # saturated by case rows and stage tests, so a second forced piece
366+ # was not worth the distribution shift. The mechanism generalizes
367+ # if that ever changes.
368+ # Re-measure rather than trusting these numbers: derandomize=True
369+ # is set on the test, so repeating THAT run returns the same count
370+ # forever and cannot disagree with itself. Drive these strategies
371+ # under derandomize=False to see the spread.
346372 # sorted for the same reason `vocab` above is: frozenset iteration
347373 # order is not stable across runs, and an unsorted pool shifts
348374 # every index sampled_from draws -- which would defeat
@@ -353,7 +379,18 @@ def _names_using(draw: st.DrawFn, lexicon: Lexicon) -> str:
353379 # pool is never empty even for an empty lexicon
354380 pieces = st .sampled_from (
355381 vocab + unspaced + glued + ["John" , "Smith" , "Q." , "," , "(" , "'" ])
356- return " " .join (draw (st .lists (pieces , min_size = 1 , max_size = 8 )))
382+ drawn = draw (st .lists (pieces , min_size = 1 , max_size = 8 ))
383+ # The one shape the pool cannot deliver on its own. Inserted at a
384+ # drawn position rather than the front: the stage takes the first
385+ # ACTIVATED-script token, not the first token, so a leading Latin
386+ # word must not hide it -- and both placements are worth covering.
387+ activatable = sorted (
388+ w + "민준" for w in lexicon .surnames
389+ if effective_script (w + "민준" ) in policy .segment_scripts )
390+ if activatable :
391+ drawn .insert (draw (st .integers (0 , len (drawn ))),
392+ draw (st .sampled_from (activatable )))
393+ return " " .join (drawn )
357394
358395
359396@given (_lexicons (), _policies (), st .data ())
@@ -363,7 +400,7 @@ def test_any_valid_config_still_parses_totally(
363400 # Building the parser is part of the contract: a Lexicon and Policy
364401 # that each constructed must also combine.
365402 parser = Parser (lexicon = lexicon , policy = policy )
366- text = data .draw (_names_using (lexicon ))
403+ text = data .draw (_names_using (lexicon , policy ))
367404 parsed = parser .parse (text ) # must not raise, ever
368405 # the anti-#100 invariant, under configuration rather than under
369406 # the default vocabulary: spans index the original exactly
0 commit comments