Skip to content

Commit b651842

Browse files
authored
Merge pull request #313 from derek73/test/fuzz-surname-split
Actually exercise the surname split under drawn lexicons
2 parents e3ddf2e + 8fd2eb3 commit b651842

1 file changed

Lines changed: 122 additions & 26 deletions

File tree

tests/v2/test_properties.py

Lines changed: 122 additions & 26 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
@@ -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

Comments
 (0)