Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 82 additions & 2 deletions tests/v2/test_ledger_guards.py
Original file line number Diff line number Diff line change
Expand Up @@ -310,9 +310,10 @@ def test_script_ranges_membership_is_decided() -> None:
_SPAN_BEARING_RULES: dict[str, frozenset[str]] = {
"expected_since_1.4.0.toml": frozenset({
"fix(#271/#272/#298)", # the canonical class
"fix(cjk-delimited-nickname)", # the three compounds, whose
"fix(cjk-delimited-nickname)", # the four compounds, whose
"fix(cjk-fullwidth-paren-nickname)", # lookaheads each carry
"fix(cjk-comma-compound)", # their own copy
"fix(cjk-comma-honorific-peel)",
}),
"expected_since_2.0.0.toml": frozenset({
"fix(#271/#272/#298)", # the canonical class
Expand Down Expand Up @@ -1015,7 +1016,7 @@ def _claim(rule: dict) -> _Claim:
"fix(cjk-maiden-marker) maiden marker consumed, compounding with the CJK order flip":
_Claim(3, ('family', 'given', 'maiden', 'middle'), "cf5c9d671c14"),
"fix(comma-family) lone post-comma piece routes to suffix/title, not first":
_Claim(236, ('given', 'suffix', 'title'), "3416f69d0ce4"),
_Claim(215, ('given', 'suffix', 'title'), "f16a0e79cba3"),
"fix(suffix-routing) two-token name with unambiguous trailing suffix stays suffix":
_Claim(751, ('family', 'given', 'suffix'), "231640fc7535"),
"fix(suffix-delimiter-rendering) no-space delimiter core token kept whole":
Expand All @@ -1030,6 +1031,8 @@ def _claim(rule: dict) -> _Claim:
_Claim(6, ('family', 'given', 'nickname'), "ae1dffa01608"),
"fix(cjk-fullwidth-paren-nickname) fullwidth-parenthesis recognition compounds with the CJK order flip":
_Claim(1, ('family', 'given', 'middle', 'nickname'), "cf370e856ae7"),
"fix(cjk-comma-honorific-peel) glued honorific peels off a post-comma given name":
_Claim(20, ('given', 'suffix'), "b2ea8fa59eea"),
"fix(cjk-comma-compound) comma routing compounds with the CJK order flip":
_Claim(20, ('family', 'given', 'middle', 'suffix', 'title'), "b2ea8fa59eea"),
"fix(cjk-honorific-suffix) postnominal honorifics recognized, compounding with the CJK order flip":
Expand Down Expand Up @@ -1125,6 +1128,83 @@ def test_every_rule_claims_the_recorded_share_of_the_corpus() -> None:
f"{sorted(set(_CORPUS_CLAIMS) - {L.name for L in _LEDGERS})}")


#: Which rule classify() actually picks, for names several rules could
#: claim. Keyed by (name, the diff it produces against that baseline).
#:
#: Every other guard here measures a rule ALONE: _CORPUS_CLAIMS records
#: what a regex reaches, and the gate's total counts names. Neither can
#: see which rule wins a contest, and that is the property #372 is
#: about -- seven of these names spent months on fix(comma-family),
#: whose prose describes none of them, with every guard green and the
#: gate reporting 108/0 throughout.
#:
#: The comma family is recorded because it is where that went wrong.
#: 42 corpus names are reachable by two or more rules in the same tier;
#: these ten are the ones whose boundaries this file argues about, and
#: pinning the argument is cheaper than re-deriving it. Note
#: 'Andrews, M.D.' diffs on the SAME {given, suffix} shape as the seven
#: peels -- only the CJK lookahead separates them, so it is the row
#: that fails if that lookahead is ever dropped.
#:
#: The diff shapes are measured against the 1.4.0 wheel, not guessed.
#: Re-measure rather than adjust them if a parser change moves one:
#: a diff shape that shifted is a finding, not a number to update.
_CROSS_RULE_WINNERS: dict[str, dict[tuple[str, tuple[str, ...]], str]] = {
"expected_since_1.4.0.toml": {
("Andrews, M.D.", ("given", "suffix")): "fix(comma-family)",
("田中, 太郎さん", ("given", "suffix")): "fix(cjk-comma-honorific-peel)",
("김, 민준씨", ("given", "suffix")): "fix(cjk-comma-honorific-peel)",
("김, 민준씨 (Jimmy)", ("given", "suffix")): "fix(cjk-comma-honorific-peel)",
("김민준, 씨", ("given", "suffix")): "fix(cjk-comma-honorific-peel)",
("김민준, 씨.", ("given", "suffix")): "fix(cjk-comma-honorific-peel)",
("선생님, J.씨", ("given", "suffix")): "fix(cjk-comma-honorific-peel)",
("이, J.씨", ("given", "suffix")): "fix(cjk-comma-honorific-peel)",
# the union rows: they move `family`, so the peel rule's fields
# exclude them and they must stay on the compound rule
("Dr 김민준씨, V.", ("family", "given", "suffix")):
"fix(cjk-comma-compound)",
("田中さん, PhD", ("family", "given", "suffix")):
"fix(cjk-comma-compound)",
("田中さん, V.", ("family", "suffix")): "fix(cjk-comma-compound)",
},
}


def test_the_recorded_rule_still_wins_each_contested_name() -> None:
"""Who explains what, which nothing else here asks.

Measured: narrowing fix(cjk-comma-compound)'s script class sends
three of these names to fix(suffix-routing) -- a fields-only
catch-all whose prose is about two-token Latin names -- and the
gate still reports 108 intentional / 0 unexplained. Reach is
per-rule, the total is per-corpus; neither notices a name changing
hands.

A failure here is not necessarily a regression: it can equally mean
a rule was narrowed correctly and its names found a better home. It
means someone has to look, which is the point.
"""
compare = load_tool("compare")
by_name = {led.name: led for led in _LEDGERS}
checked = 0
for ledger_name, winners in _CROSS_RULE_WINNERS.items():
ledger = by_name[ledger_name]
rules = compare._sorted_rules(_rules(ledger))
never = _exclusions(ledger)
for (name, fields), expected in winners.items():
got = compare.classify(name, set(fields), rules, never)
assert got is not None and got.startswith(expected), (
f"{ledger_name}: {name!r} diffing {list(fields)} is now "
f"explained by {got!r}, not {expected!r}. Check the new "
f"rule's prose actually describes this name before "
f"recording it -- a rule claiming a diff it does not "
f"describe is #372, and it stays green everywhere else")
checked += 1
assert checked, "no contested name was checked, so this pin is vacuous"
assert set(_CROSS_RULE_WINNERS) <= {led.name for led in _LEDGERS}, (
f"_CROSS_RULE_WINNERS names ledgers that do not exist: "
f"{sorted(set(_CROSS_RULE_WINNERS) - {L.name for L in _LEDGERS})}")


class _Excluded(NamedTuple):
"""What a [[never]] entry silences, in the two dimensions that can
Expand Down
126 changes: 79 additions & 47 deletions tools/differential/expected_since_1.4.0.toml
Original file line number Diff line number Diff line change
Expand Up @@ -121,45 +121,42 @@ issue = "fix(comma-family) lone post-comma piece routes to suffix/title, not fir
# (family/`last` is unchanged either way -- "pre-comma is definitionally
# family").
#
# #312 (the glued-honorific peel site move) sent two of its five
# DIFFING corpus rows here instead of the CJK-comma rule below, and it
# is worth recording why, since this rule's prose has nothing to do
# with CJK. Diffing, not new: four of the five are rows #312 added,
# while '田中さん, PhD' was already in the corpus and #312 changed only
# what it parses to. #312 added a fifth row that does NOT appear here,
# '田中さん, 太郎' -- it agrees with 1.4.0, so it never reaches
# classify() at all; it is the pinned guard for the case a naive fix
# breaks (the peel site there is 太郎, so nothing peels).
# '김, 민준씨' and '田中, 太郎さん' diff as {first, suffix}: the glued
# honorific peels off the post-comma given name into `suffix`
# (민준씨->민준/씨, 太郎さん->太郎/さん), and that field pair happens to
# be a subset of this rule's [first, title, suffix]. No title moved and
# the peeled token is CJK, not a Latin credential -- this rule doesn't
# know or care, because compare.py's classify() matches purely on
# regex-then-field-superset, first rule to satisfy both wins, and this
# rule sits earlier in file order (name_regex tier, written-order tie-
# break) than fix(cjk-comma-compound) below. The other two comma rows,
# 'Dr 김민준씨, V.' and '田中さん, PhD', diff with `last` in the mix
# (v1 read the whole pre-comma run as `first`; 2.0 splits family into
# `last`), which is outside this rule's fields, so they correctly fall
# through to fix(cjk-comma-compound). The fifth row, '威廉·莎士比亚さん',
# has no comma at all -- its dot is U+00B7 (间隔号), not a comma -- so
# it skips every comma-keyed rule and the honorific-suffix rule too (no
# space precedes its glued さん), landing on the fields-only
# fix(suffix-routing) tier by its plain {first, last, suffix} shape,
# same as any glued Latin honorific. Verified by the 2026-08-01 run,
# not assumed: traced through compare.py's classify(), which tries
# name_regex rules before fields-only ones and, within a tier, file
# order, returning the first rule whose regex (if any) matches the
# string and whose fields are a superset of the diff. No rule here
# inspects the honorific token itself -- the field shape alone decides.
# The coverage pass that followed added one more row here, '김, 민준씨
# (Jimmy)': same {first, suffix} diff as '김, 민준씨' -- the extracted
# nickname matches on both sides and so is not part of the diff -- and
# it lands on this rule for the same reason. The limit row recorded
# next to it, '田中さん, V.', appears under no rule at all: it is
# parity with 1.4.0, so it never reaches classify().
name_regex = ","
# LATIN-ANCHORED, and that is what keeps this rule describing what it
# claims. The regex was a bare comma until #372, which reaches every
# comma in every script -- so seven CJK glued-honorific names landed
# here, on a rule whose prose has nothing to do with CJK. They diff as
# {given, suffix} (민준씨->민준/씨, 太郎さん->太郎/さん), which is a
# subset of this rule's fields, and classify() takes the first rule
# whose regex matches and whose fields are a superset. Both this rule
# and fix(cjk-comma-compound) carry a name_regex, so they share a tier
# and file order broke the tie -- in favour of the one written first,
# which was this one.
#
# Measured, narrowing it: corpus reach 236 -> 215, and this rule now
# claims exactly one name, 'Andrews, M.D.', which is the shape named
# above. All seven moved to fix(cjk-comma-compound), which grew 10 ->
# 17 and is the rule named for that shape. The gate is unmoved at 108
# intentional / 0 unexplained -- nothing became unexplained, so no name
# lost an explanation, it only changed which rule gave it.
#
# The cut is at U+0250, the threshold _is_latin_only uses in
# compare.py, matching the trailing-'Ph. D.' exclusion. Anchored at
# both ends because a name is Latin or it is not: an unanchored class
# would match the Latin part of a mixed name and take it back.
#
# One row nearby is not here and never was: '田中さん, 太郎' agrees
# with 1.4.0, so it never reaches classify() at all. It is the pinned
# guard for the case a naive #312 fix breaks (the peel site is 太郎, so
# nothing peels).
#
# '田中さん, V.' is NOT that case, though this comment said it was
# until #372 checked. It diffs {family, suffix} and lands on
# fix(cjk-comma-compound) below, before and after this change. It
# reads as parity only under Policy(lenient_comma_suffixes=False) --
# the knob tests/v2/cases.py exercises in
# ja_honorific_glued_family_comma_strict_knob -- and the corpora run
# under the default policy, so here it is a real diff.
name_regex = "^[\\u0000-\\u024f]*,[\\u0000-\\u024f]*$"
fields = ["given", "title", "suffix"]

[[change]]
Expand All @@ -169,9 +166,10 @@ issue = "fix(suffix-routing) two-token name with unambiguous trailing suffix sta
# `suffix`.
# #312: also claims comma-less glued-honorific names like
# '威廉·莎士比亚さん' by the same {first, last, suffix} shape as any
# glued Latin honorific -- see the note on fix(comma-family) above for
# why its four comma-bearing siblings split between that rule and
# fix(cjk-comma-compound) instead.
# glued Latin honorific. Its comma-bearing siblings never reach here:
# they split by whether the diff moves `family` --
# fix(cjk-comma-compound) when it does, fix(cjk-comma-honorific-peel)
# when it does not -- both above, both keyed on a comma.
fields = ["given", "family", "suffix"]

[[change]]
Expand Down Expand Up @@ -291,6 +289,38 @@ issue = "fix(cjk-fullwidth-paren-nickname) fullwidth-parenthesis recognition com
name_regex = "(?s)(?=.*[()])(?=.*[\\u3005-\\u3006\\u3040-\\u309F\\u30A0-\\u30FF\\u3400-\\u4DBF\\u4E00-\\u9FFF\\uF900-\\uFAFF\\uAC00-\\uD7A3\\uFF65-\\uFF65])"
fields = ["given", "middle", "family", "nickname"]

[[change]]
issue = "fix(cjk-comma-honorific-peel) glued honorific peels off a post-comma given name"
# '김, 민준씨' / '田中, 太郎さん': the comma already fixes family/given
# order on both sides, so NO order flip happens -- `family` is
# byte-identical. The only change is #312's peel: the honorific glued
# to the post-comma given name comes off into `suffix` (민준씨 ->
# 민준/씨, 太郎さん -> 太郎/さん). Diff is exactly {given, suffix} on
# all seven names this rule claims.
#
# Its own rule because neither neighbour describes it, and #372 is
# about rules explaining what they describe. fix(comma-family) above
# is the Latin lone-post-comma-piece routing and is now anchored to
# Latin, so it cannot reach these at all. fix(cjk-comma-compound)
# below is for the UNION -- comma routing AND the family-first flip
# together -- which is why its fields include `family`; these names
# move no family, so claiming them there would be the same field-shape
# coincidence #372 was filed over, relocated rather than fixed.
#
# `fields` is what separates the two, not file order. The union rows
# ('Dr 김민준씨, V.', '田中さん, PhD') diff with `family` in the mix,
# which is outside this rule's [given, suffix], so they cannot match
# here however early it sits and still fall through to the compound
# rule. Written above it anyway, so the narrower rule is also the
# earlier one and the tie never has to be reasoned about.
#
# The regex is the same comma-plus-classified-codepoint pair the
# compound rule carries, hand-copied from _SCRIPT_RANGES and pinned by
# the same sync test -- a comma alone matches every Latin 'Smith, Jr.'
# in the corpus.
name_regex = "(?s)(?=.*,)(?=.*[\\u3005-\\u3006\\u3040-\\u309F\\u30A0-\\u30FF\\u3400-\\u4DBF\\u4E00-\\u9FFF\\uF900-\\uFAFF\\uAC00-\\uD7A3\\uFF65-\\uFF65])"
fields = ["given", "suffix"]

[[change]]
issue = "fix(cjk-comma-compound) comma routing compounds with the CJK order flip"
# '威廉·莎士比亚, PhD': one name, two intended changes at once -- the
Expand All @@ -309,11 +339,13 @@ issue = "fix(cjk-comma-compound) comma routing compounds with the CJK order flip
# #271/#272 substrings it avoids were a constraint of a selector
# retired in #333.
#
# #312 sends only the two glued-honorific comma rows whose diff
# includes `last` here ('Dr 김민준씨, V.', '田中さん, PhD'); the two
# whose diff is only {first, suffix} ('김, 민준씨', '田中, 太郎さん')
# match fix(comma-family) first instead, purely on file order -- see
# the note there for why.
# #312 sends only the glued-honorific comma rows whose diff includes
# `family` here ('Dr 김민준씨, V.', '田中さん, PhD') -- the union this
# rule is for. The rows whose diff is only {given, suffix} peel
# without any order flip and belong to fix(cjk-comma-honorific-peel)
# above, which names that shape and whose fields exclude `family` so
# the two cannot compete. Until #372 they landed on fix(comma-family)
# instead, on nothing but file order.
name_regex = "(?s)(?=.*,)(?=.*[\\u3005-\\u3006\\u3040-\\u309F\\u30A0-\\u30FF\\u3400-\\u4DBF\\u4E00-\\u9FFF\\uF900-\\uFAFF\\uAC00-\\uD7A3\\uFF65-\\uFF65])"
fields = ["given", "middle", "family", "title", "suffix"]

Expand Down