Skip to content

Commit 29d2e4e

Browse files
committed
Actually exercise the surname split under drawn lexicons
The manufactured 'surname + 민준' token was in the pool but rarely in the name: one entry among ~30, against a name of 1-8 pieces. Measured, the lexicon and policy agree on ~14 draws in 250 but the token reached the name only 0-4 times -- and every time it did, the split fired. The shortfall was sampling, not the stage, so insert one at a drawn position when the draw admits it. Twelve randomized runs of 250 now give 0-19 fires (median 8) where they gave 0-2. Also corrects this comment's own claim. It called the surname half 'structurally inert, not luck' on the strength of a single derandomize=True run reporting zero -- but that run is one sample and repeating it cannot disagree with itself. The structural part is real and narrower than stated: a non-hangul surname makes a mixed-script token that can never be a site. The rest was luck.
1 parent e3ddf2e commit 29d2e4e

1 file changed

Lines changed: 57 additions & 20 deletions

File tree

tests/v2/test_properties.py

Lines changed: 57 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from nameparser._lexicon import _VOCAB_FIELDS
2020
from nameparser._pipeline import run
2121
from nameparser._pipeline._state import ParseState
22+
from nameparser._pipeline._vocab import effective_script
2223
from nameparser._types import AmbiguityKind, Role
2324

2425
from .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

Comments
 (0)