Skip to content

Consolidate the script codepoint table in _policy; classify 〆 as Han - #303

Merged
derek73 merged 11 commits into
masterfrom
v2.1/script-ranges
Jul 30, 2026
Merged

Consolidate the script codepoint table in _policy; classify 〆 as Han#303
derek73 merged 11 commits into
masterfrom
v2.1/script-ranges

Conversation

@derek73

@derek73 derek73 commented Jul 30, 2026

Copy link
Copy Markdown
Owner

Summary

Relocation refactor from the 2.1 CJK work's follow-up list, plus one small behavior fix that rides the same table entry.

The move. _SCRIPT_RANGES — the codepoint table behind every script decision — moves from _pipeline/_vocab.py to _policy.py, beside the Script enum it describes. Layering bars packs from importing the pipeline, so the old placement had forced two hand-copies (zh._HAN_RANGES, ja._JA_RANGES), each with a sync test. All of that dissolves: packs and pipeline now read one table.

The factory. A new _policy._script_matcher(*scripts, whole=False) compiles a character class from the union of the named scripts' spans and returns a closure predicate (contains-any, or wholly-of with whole=True). The compiled pattern living inside the closure is load-bearing: a module-level re.Pattern in a pack flips the marker-pack classification in test_locales.py, and a pack importing re at all trips the registry gate — a new pin asserts zh/ja stay out of that classification. The zh/ja DEVIATES gates and the ja adapter's repertoire guard now derive through the factory (the per-char loops they replace measured ~9× slower on short tokens, ~70× on long ones), as do _vocab's own script classifiers — a review-driven extension that deleted the last duplicate derivations.

〆 (U+3006) classifies as Han — the one behavior change. 〆 opens real Japanese surnames (〆木, 〆谷, 〆野) but carries Script=Common under UAX #24, so this is the block table deliberately reaching past the Script property (々's singleton was the opposite case: Script=Han outside every ideograph block). Effect: 〆-surnames gain the East Asian family-first reading. The ja segmenter adapter declines 〆-bearing tokens — namedivider 0.4.x's rule path cuts every attested 〆 surname one character early at confidence 1.0, which would have produced a wrong family with no SEGMENTATION report; the decline is pinned by a stub test that runs without the extra. The differential harness's hand-copied character class widened in lockstep (its sync pin tripped mid-commit, as designed) — the one irreducible copy, since TOML can't import Python.

Test plan

  • Full suite: 2637 passed, 13 skipped, 11 xfailed (ja integration tests included)
  • mypy, ruff, sphinx all clean
  • Differential harness vs pinned 1.4: 654 names, 18 intentional diffs, 0 unexplained
  • Behavior-neutrality of the refactor commits probed empirically (8000-input classifier diff, zero mismatches)
  • parse("〆木 太郎") → family 〆木, given 太郎; parse("〆木太郎") → family whole

🤖 Generated with Claude Code

derek73 and others added 9 commits July 29, 2026 20:18
The codepoint table is data about Script -- which spans each enum
member covers -- so it belongs beside the enum in the config layer,
not in the pipeline. With one copy readable from below the layering
boundary (packs may import _policy, never the pipeline), the
hand-copied spans in the zh/ja locale packs can die in the following
commits instead of being kept in sync by hand.

This deliberately reverses the #271 placement decision that put the
table in _pipeline/_vocab.py: that placement is what forced each pack
to hand-copy its spans in the first place. _vocab now imports the
table and still derives its per-script patterns from it; behavior is
unchanged, and only test imports retarget.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A factory beside _SCRIPT_RANGES that compiles one character class
from the union of the named scripts' spans and returns a named
closure predicate (an empty union raises ValueError rather than a
cryptic pattern error). Two modes: whole=False answers CONTAINS-any
-- DEVIATES' contract, where over-declaring is the gate's safe
direction; whole=True answers non-empty-and-WHOLLY -- the ja
adapter's repertoire guard.

The compiled re.Pattern lives inside the closure, and compilation
lives in _policy rather than in a pack-local closure, deliberately:
tests/v2/test_locales.py classifies any pack module holding a
module-level re.Pattern as a marker pack requiring rotator branch
coverage, and its registry gate fails a pack that merely IMPORTS re
without exposing one -- so packs must not import re at all.
Predicates built here keep the zh/ja packs invisible to that sweep
by construction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The hand-copied Han spans and their sync test die because the table is
now importable from _policy. DEVIATES keeps its documented
def-with-docstring shape (the pack-authoring contract in
docs/locales.rst) and delegates to the factory closure; the
per-character loop is gone (the compiled character class was measured
16-30x faster at DEVIATES' scan scale).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The three-script hand-copy (_JA_RANGES) and both per-char loops die:
the pack now names its scripts once (_JA_SCRIPTS) and lets _policy's
_script_matcher compile the two predicates from the shared codepoint
table, so there are no spans here to drift out of sync. DEVIATES
keeps the documented def shape, delegating to the contains-any
matcher; the adapter's guard becomes one whole-token predicate call
with identical semantics (empty text declines one check earlier, same
None outcome). The sync pin in tests/v2/test_locales.py is replaced
by floor/ceiling declaration assertions in test_ja_pack_contents.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The two hand-written class-construction expressions were semantically
identical to factory calls: _SCRIPT_PATTERNS was per-script wholly-of
in the table's key order, _JA_PATTERN wholly-of over the three-script
union. Folding them into _script_matcher(whole=True) deletes the last
derivation duplicates of the factory's character-class expression and
the _JA_PATTERN roster row in test_regex_sync (neither replacement is
a module-level re.Pattern, so the completeness sweep no longer sees
them). This is a review-driven addition beyond the original spec's
"vocab matchers stay unchanged" non-goal, taken because the shared
factory made that non-goal's rationale moot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
_MARKER_REGEX_PACKS classifies any pack holding a module-level
re.Pattern as a marker pack whose regex branches the rotator sweep must
cover. Since zh and ja now derive their DEVIATES predicates through
_script_matcher, that boundary is load-bearing: the factory's closure is
what hides the compiled class and keeps range-declaring packs out of a
branch-coverage sweep a codepoint-range predicate cannot satisfy.
Nothing pinned it -- a pack author who "simplifies" a closure back to a
module-level compiled pattern would silently flip zh or ja into the
sweep. The new test converts that failure from silent to loud, and the
comment above the derivation now names both packs (it predated ja's
switch and named only zh).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The one behavior change of this branch: the HAN entry's opening span
widens from the 々 singleton (0x3005, 0x3005) to (0x3005, 0x3006),
taking in 〆, the shime mark. Unlike 々 -- Script=Han under UAX #24,
merely outside every CJK ideograph block -- 〆 carries Script=Common,
so this is the table deliberately reaching PAST the Script property,
not around a block boundary. It earns the reach: 〆 functions solely
as a component of Japanese surnames (〆木 Shimeki, 〆谷 Shimetani,
〆野) and appears in no other script's names. Before the change 〆木
was a mixed-script token: unlicensed, positional, given-first -- the
name reversed and never gated into segmentation.

The widening also makes 〆木太郎 wholly Japanese to the ja pack's
adapter, which would have handed it to namedivider -- whose 0.4.x
rule path cuts every attested 〆 surname at offset 1 (〆 | 木太郎,
score 1.0, algorithm='rule'; measured): a wrong family arriving with
NO SEGMENTATION report, since 1.0 clears the floor. The adapter now
declines 〆-bearing tokens before the divider is consulted (pinned
stub-based in tests/v2/test_locales.py, no extra needed): the
family-first ORDER fix stands regardless, and division can return
when a divider knows the mark.

The differential toml's character class is a hand copy of the table,
and its sync guard tripped in between exactly as designed:
test_differential_cjk_rule_matches_the_script_ranges failed with
"expected_changes.toml's CJK name_regex declares [(12293, 12293),
...]; _SCRIPT_RANGES' BMP spans are [(12293, 12294), ...]" until the
CJK rule's 々-々 was widened to 々-〆 to match.
Differential harness re-run by hand: 654 corpus names, 18 intentional
diffs, 0 unexplained, exit 0 -- neither corpus contains a 〆 name, so
totals are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Closing sweep for the _SCRIPT_RANGES move to _policy.py. Two stale
spots, both corrected:

- nameparser/_policy.py: _script_matcher's docstring claimed the
  closure keeps derived matchers out of test_regex_sync.py's hand-copy
  completeness sweep as if that sweep scanned everywhere; it scans a
  fixed set of pipeline modules (_assign, _post_rules, _render,
  _tokenize, _vocab), which covers _vocab but never _policy or the
  packs. Reworded to claim only what holds: the closure spares
  _vocab's derived matchers a declaration row in that sweep.

- docs/locales.rst: the contributing contract told authors to declare
  DEVIATES but never said how a range-declaring pack builds one, while
  test_locales.py now reads any module-level re.Pattern as a marker
  regex needing branch coverage and fails a pack importing re without
  one. Added the mechanism: build the predicate with _policy's
  _script_matcher, the way zh and ja do, never by compiling private
  character ranges.

Verified rather than edited: AGENTS.md's test_regex_sync.py line
("wherever the copy lives -- including copies outside the package")
stays true because the differential toml copy in tools/ remains and is
still pinned; its layering line already lists _policy among allowed
pack imports. Acceptance greps: 0x4E00 appears in the package only in
_policy.py's table; the dead symbols (_HAN_RANGES, _JA_RANGES,
_in_repertoire, _SCRIPT_PATTERNS, _JA_PATTERN) are gone from
nameparser/, tests/, and tools/; no pack references _vocab.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@codecov

codecov Bot commented Jul 30, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.43%. Comparing base (3d7be77) to head (65b6093).

Additional details and impacted files
@@           Coverage Diff           @@
##           master     #303   +/-   ##
=======================================
  Coverage   98.43%   98.43%           
=======================================
  Files          41       41           
  Lines        2739     2750   +11     
=======================================
+ Hits         2696     2707   +11     
  Misses         43       43           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@derek73 derek73 self-assigned this Jul 30, 2026
@derek73 derek73 added this to the v2.1 milestone Jul 30, 2026
derek73 and others added 2 commits July 29, 2026 23:45
Comment corrections:
- _policy.py: scope the 〆 justification to personal names -- the mark
  is a general-purpose Japanese symbol (〆切, the envelope closing
  mark); only its within-names use is surname-only
- _policy.py: "a pack must not import re" -> "a range-declaring pack"
  (marker packs like ru/tr_az import re legitimately, by exposing
  module-level patterns)
- _policy.py: the regex-sync sweep scans the pipeline modules PLUS
  _render, and only private (underscore-prefixed) module-level
  patterns -- say so
- _policy.py: replace the hand-copy PR-history parenthetical with the
  durable constraint (the table lives here because packs must not
  import the pipeline)
- locales/ja.py: reflow two ragged comment wraps (wording unchanged)
- locales/ja.py: acknowledge the blanket 〆 decline's deliberate
  breadth -- mid-token 〆 (山〆太郎) measures 0.375 on the
  kanji_feature path and WOULD have carried a SEGMENTATION report;
  swept in anyway because decline is the safe direction

New pins:
- test_locales.py guard-stack stub test: assert seg("Yamada太郎") is
  None -- kills a mutation dropping whole=True from _wholly_japanese,
  which survived the whole suite
- test_locales.py pack-contents tests: _zh/_ja.DEVIATES("𠮷田") --
  pins the astral extent of the pack predicates, which a BMP-only
  hand-rolled replacement would otherwise survive
- cases.py ja_shime_with_kana_given ("〆木 ひろ") -- the kana license
  composing with the widened Han span, previously untested together
- test_policy.py test_every_script_member_has_a_range_entry -- a
  future Script member without a _SCRIPT_RANGES entry is accepted by
  Policy validation yet never classified; this turns that silent
  no-op into a failure
- test_locales.py test_namedivider_still_miscuts_shime_names -- the
  self-expiring canary behind the adapter's 〆 decline; when
  namedivider learns the mark this fails and the decline gets
  revisited instead of lasting forever

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… prose

* Hoist _JA_SCRIPTS into _policy.py beside _SCRIPT_RANGES: the union
  was defined twice (_pipeline/_vocab.py frozenset, locales/ja.py
  tuple) with nothing pinning their membership equality -- the same
  drift class this branch already deleted at span level. One tuple
  now serves the kana license, the ja pack's DEVIATES, and the
  adapter's repertoire guard; the katakana rationale moves onto the
  ja predicates it explains, and _vocab's naming-defense comment
  (guarding a constraint that never existed) goes away.
* Merge _script_matcher's twin closures: one compiled [cls]+ pattern
  with the bound method chosen by `whole` -- search over [cls]+ is
  exactly contains-any, so the second pattern and closure bought
  nothing.
* Reorder ja adapter guards: the O(1) length check now leads the
  chain, so routine one-char tokens from spaced input ("林 太郎") no
  longer pay the regex fullmatch and the 〆 scan (measured 4x) before
  being declined. All guards return the same None, so behavior is
  unchanged.
* Trim four prose duplications: the 〆 paragraph's copy of the 々
  closer in _policy's table comment (plus an orphan line wrap), the
  triple-stated ordering point on _SCRIPT_MATCHERS, locales.rst's
  restatement of the marker-classification contract the factory
  docstring already carries, and the twice-narrated namedivider
  mis-cut measurement in test_locales.py (the authoritative account
  stays at ja.py's decline comment).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@derek73
derek73 merged commit 58e93d7 into master Jul 30, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant