Skip to content

Peel glued CJK honorifics off the end of a name (#308) - #311

Merged
derek73 merged 36 commits into
masterfrom
claude/glued-honorific-peel-plan-47792b
Jul 31, 2026
Merged

Peel glued CJK honorifics off the end of a name (#308)#311
derek73 merged 36 commits into
masterfrom
claude/glued-honorific-peel-plan-47792b

Conversation

@derek73

@derek73 derek73 commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Summary

CJK honorifics written glued to a name — 田中さん, 山田太郎様, 김민준씨 — were absorbed into the name, because suffix matching is whole-token. script_segment now splits a listed honorific off the end of the name part's last non-post-nominal token, as a split (sub-slices of the original, spans unchanged), so ordinary suffix classification claims it and the stage's existing surname-split and segmenter machinery see the name without it.

  • New GLUED_HONORIFICS vocabulary (15 entries) and Lexicon.honorific_tails carrying it, guarded by a _SUBSET_FIELDS invariant — every tail must also be a suffix word, or the peel strands a fragment inside the name.
  • The glued set is strictly narrower than the spaced one: an entry peels only where it can never end a name. 양, 군, 氏, 博士 and 殿 ship spaced-only (김지양 is a given name; ~90 Japanese surnames end in 殿); 君 is in neither set, though its kana spelling くん peels.
  • A shared _is_post_nominal predicate governs two decisions with deliberately different verdicts: the peel site scans back over trailing post-nominals, while the surname site declines when the leading script-written token is one. Skipping there split 양 지훈 — Yang Jihoon — into given 지 / middle 훈.
  • Under a configured Japanese segmenter, a peel-manufactured tail no longer counts as a boundary its writer drew, so 山田太郎様 divides. Spaced forms are deliberately untouched: 佐藤 氏 keeps its surname whole, because there the writer did draw the boundary.

Default-on. Reaches HumanName as well as the 2.0 API. Release-log entries cover the upgrade surface, including that glued 田中さん now divides to 田・中 under the ja pack.

Test Plan

  • uv run --extra ja pytest -q — 2844 passed, 16 skipped, 11 xfailed
  • uv run mypy — clean, 96 files
  • uv run ruff check nameparser/ — clean
  • sphinx-build -b html and -b doctest — clean, 207 doctests, 0 failures
  • tools/differential/compare.py vs nameparser 1.4.0 — 726 names, 84 intentional diffs, 0 unexplained
  • Every commit independently green (verified by per-commit export)
  • Mutation pass: 21 mutants, 17 killed, 2 provably equivalent; the surviving two are now pinned

Review notes

Two design calls were made, then reversed on measured evidence — both reversals are their own commits so the reasoning survives:

  • 17f9c39be97aec: the surname site skipped post-nominals and fell through to the given name. 양 지훈 is why it declines instead.
  • 59aac1f063fd1a: the segmenter carve-out exempted any recognized honorific, which took four correct spaced outputs (佐藤 氏, 田中 様, 鈴木 先生, 中村 教授) and broke them to win one. Narrowed to the tail the peel manufactures.

Also corrected in passing: two case-table rows labelled parity that diverge from 1.4.0 (verified against the real wheel) are now fix(#271).

Closes #308.

🤖 Generated with Claude Code

derek73 added 23 commits July 30, 2026 20:53
The neighbour precondition exempted any recognized post-nominal, which
reached well past the case it was written for: it broke the SPACED
forms, whose writers drew an actual boundary. Under the JA pack
"佐藤 氏" divided 佐 + 藤, "田中 様" 田 + 中, "鈴木 先生" 鈴 + 木,
"中村 教授" 中 + 村 -- while glued 佐藤氏, which never peels (氏 is
deliberately not a tail), stayed correct. That inverts the branch's
own doctrine: the spaced position is a token boundary its writer drew.

Narrow the carve-out to the tail the peel MANUFACTURED. The peel tags
the token it cuts off and the neighbour test reads that tag instead of
asking the vocabulary -- which cannot separate the two spellings, since
they put the same word in the same place and only the provenance
differs. A glued honorific means the writer drew no boundary anywhere;
a spaced one means they drew one and wrote the name beside it as a
unit.

The motivating fix is untouched: glued 山田太郎様 still reaches the
segmenter as 山田太郎, and 田中さん still divides under the pack.
_is_post_nominal loses a caller and says so.
선생님, 교수님 and 박사님 are the three standard -님 professional
honorifics; the first two shipped in both sets and the third in
neither, which broke both of its spellings. Glued, 김민준박사님 peeled
님 alone and stranded 박사 in the given name; spaced, 김민준 박사님 was
cut by the peel -- the token is not a whole-token suffix word -- and
came back as two suffixes for one honorific. Both now give suffix
박사님.

The case row that used 김민준박사님 to pin the one-peel/no-recursion
rule can no longer demonstrate it, and no shipped entry pair has the
shape that could: it needs a tail whose remainder ends in a DIFFERENT
tail, the two not forming a listed entry themselves. The pin stays at
stage level, where a synthetic lexicon can build that shape, and the
row is repointed at the behavior it now shows.
Nothing here changes behavior; each is a statement a later reader
would have built on.

- The ASCII bail justified itself purely in terms of scripts, which
  was true when only the surname half existed. It now also
  short-circuits the peel, which has no script gate, so a configured
  ASCII tail never fires and an accent on an unrelated token switches
  it on. Both stated at the bail and at the public field, matching
  rather than changed: correct for the CJK vocabulary that ships, and
  changing it needs its own vetting pass.
- The peel's maiden paragraph documented the direction that fires and
  not the one that does not: "김민준씨 née 박" still gives given
  민준씨, and "김민준씨 née 박씨" routes the maiden's honorific while
  leaving the person's own. Stated as a limit, with why chasing it
  here is not possible (classify has not run).
- _longest_entry's cache is keyed by the frozenset VALUE, so lexicons
  sharing KOREAN_SURNAMES collapse to one entry; the "times the two
  fields" multiplication overstated it.
- _SUBSET_FIELDS' header generalized "each marker NARROWS its base",
  which honorific_tails inverts -- it widens where a suffix word may
  match. Its base is also narrower than what classifies the peeled
  piece (suffix_as_written ORs in the non-ambiguous acronyms); that
  is a trade, recorded so nobody fixes it.
- GLUED_HONORIFICS' exclusion list was headed "these ship spaced
  only" while its last bullet named two entries that ship in neither
  set. Split the two kinds.
- The kana-vs-kanji vetting note claimed hiragana spells no name
  character, true only within its implicit "in Chinese" -- this
  project's own case table pins the hiragana given name 高橋みなみ.
honorific_tails appeared nowhere in tests/v2/test_config_shim.py, and
the snapshot-equality test passes VACUOUSLY here -- with the default
config the GLUED_HONORIFICS ∩ suffix_words intersection is a no-op,
which is exactly the failure mode AGENTS.md warns about. Pin the case
the translation decides: deleting a CJK honorific from
Constants.suffix_not_acronyms, the only v1 knob that reaches the
field, turns the peel off rather than raising the subset error or
leaving a tail that peels into a suffix field no longer recognizing
it.

AGENTS.md's roster of shim transformations said four and named four;
this branch added the fifth. Same-commit rule applied one commit late.
Two overclaims in the user docs and four stale notes in the tests.

usage.rst said the honorific is split off "before anything else reads
the name". Comma structure and interpunct detection both run first and
both gate the peel: "田中さん, 太郎" keeps the whole 田中さん as the
family name, pinned by test_family_comma_skips_the_peel and documented
nowhere. Said now, next to the comma rule it belongs to.

Both usage.rst and the release log promised that the spaced spelling
"composes the same way" and warned that a spaced family-name-only
address would start dividing. The previous commit that narrowed the
carve-out makes both false: spaced forms are unaffected, and the
release log's ja-pack paragraph now says which spelling divides and
which does not, and offers the spaced spelling as the opt-out it now
is.

The case-table notes: the lone-씨 row pointed at the length cap when
the peel-site scan is what holds it, ja_sama_spaced still forward-
pointed to work that has landed, the lone-さん row named its twin by
position and got the wrong row, and test_lexicon's rationale credited
an ambiguous subtraction that happens only in the v1 shim.
A mutation pass found one live survivor on a fully-covered line:
replacing the peel's `state.segments[0]` with `range(len(state.tokens))`
-- the natural simplification, since under NO_COMMA the two runs are
usually identical -- changes real output and fails nothing. Extracted
delimited content is still in the token stream at this stage with only
its role set, so "김민준씨 (Jimmy)" would hand the scan-back Jimmy as
its site: no post-nominal to step over, no tail to peel, and the peel
lost entirely. The comment above the segment lookup made it worse by
saying extracted content is unreachable and that no input can produce
it -- true of the surname half it was written for, actively misleading
for the peel, where the choice is what keeps Jimmy out. Case row plus
both comments.

Two boundaries a reader could otherwise meet by surprise:

- "田中さん, PhD" keeps the whole 田中さん as the family name while
  "田中さん PhD" gives family 田中 and suffix "さん, PhD". A one-word
  name part before a comma is FAMILY_COMMA, which opts the stage out
  by doctrine -- the one spelling disagreement #308 does not remove,
  where the 김민준씨 Jr. pair got two rows for removing exactly that.
  A case row, so it is a recorded decision.
- The off-switch docs/customize.rst promises was verified and
  untested: removing a tail leaves the glued name unsplit while the
  spaced spelling still routes the honorific. Pinned at parse level,
  against a peel-is-on baseline so it cannot pass vacuously.

And a clause recording why _is_post_nominal is strict rather than
lenient: the initial veto makes "田中さん V." decline where
"田中さん II" peels, which agrees with what classify does with "V."
downstream. Swapping the predicate fails no test; the clause is the
record instead.
The property layer draws honorific_tails and repairs the draw, but
_names_using only manufactured `surname + 민준`, so no generated token
ever ENDED in a drawn tail and the peel never fired under a drawn
lexicon. Instrumented before and after: 14 fires across
test_properties.py, all 14 under Lexicon.default() and 0 under a drawn
one; deriving the glued token from the draw moves that to 2. Both are
ASCII tails on a non-Latin stem ('민준de', '민준and'), which is the one
shape that reaches an ASCII tail at all -- the stage bails on a wholly
ASCII original.

Same failure mode the pool's own comment records having measured and
fixed for surnames. The comment now covers both derivations, and no
longer states a per-run fire count for the surname half as fact: the
same instrumentation shows zero surname-half splits under drawn
lexicons in this run, so the honest note is to instrument before
concluding either half is exercised.
@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.46%. Comparing base (427c01e) to head (53611b1).

Additional details and impacted files
@@            Coverage Diff             @@
##           master     #311      +/-   ##
==========================================
+ Coverage   98.45%   98.46%   +0.01%     
==========================================
  Files          41       41              
  Lines        2776     2803      +27     
==========================================
+ Hits         2733     2760      +27     
  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 added 6 commits July 31, 2026 11:04
The clause read "ONE peel, never recursive: 김민준박사님 gives up its
님 and keeps its glued 박사". Shipping 박사님 falsified it in the same
branch: longest-first now reaches the whole honorific, and that input
measures {'given': '민준', 'family': '김', 'suffix': '박사님'}.

Every other home of the claim was updated when 박사님 landed -- the
case row renamed and re-expected, the stage test moved to the
synthetic _TAILS that deliberately omits 박사님, both docs. This
docstring was the survivor, and it is the one a maintainer reads while
editing the peel.

State the rule with an honest witness instead: no shipped pair has the
shape the pin needs, so the synthetic lexicon in
test_one_peel_never_a_stack is the only one left.
The row for "田中さん, PhD" was classified fix(#271) with the note "the
fields here are the CJK order flip's". The observation behind it was
right -- the only deviation from 1.4.0 is first -> family -- but the
attribution was untested, and it is wrong.

Measured, 1.4.0 against HEAD:

    Smith, PhD    first Smith  ->  family Smith
    Smith, Dr.    first Smith  ->  family Smith
    田中さん, PhD  first 田中さん ->  family 田中さん

"Smith, PhD" is pure Latin, so #271's script-conditional rules cannot
be touching it, and it makes the identical move. The behavior is
FAMILY_COMMA's and predates #271; the table already has the slug for
it, used by family_comma_lone_title.

Also rename ko_ -> ja_: the input is Han plus hiragana, and the table
prefixes rows by their input's script.
The narrowing was explained in four places as "its writer drew that
boundary and chose to write 山田太郎 as a unit". That is false for
exactly the input it describes: in "山田太郎 様" the unit the writer
drew is the whole name, honorific and all. The neighbour precondition
exists to detect a boundary WITHIN the name ("山田 太郎"), and a
trailing honorific is not one in either spelling.

What the code actually relies on is weaker: by position a spaced
honorific is indistinguishable from a spaced name element, so the
precondition conservatively counts it. State that, and the measured
trade that makes it worth taking -- exempting spaced honorifics would
divide 佐藤 氏, 田中 様, 鈴木 先生 and 中村 教授 (all four divide bare
under the JA pack) to win the single division 山田太郎 様.

Fixed in the module docstring, the inline comment above the
precondition, docs/usage.rst and the release log's #308 bullet.
usage.rst promised that "writing the honorific spaced is therefore
also how you ask for a family name to be kept whole, short of
declining the pack", and the release log carried the same closing
sentence. Both generalize a fact about the SEGMENTER path to a
paragraph a Korean- or Chinese-data reader also reads.

Measured, the vocabulary path does not part company at all:

    김민준 씨   -> family 김, given 민준, suffix 씨   김민준씨 identical
    王小明 先生 -> family 王, given 小明, suffix 先生  王小明先生 identical (zh)

Spacing buys nothing there, and "short of declining the pack" is not
even available for hangul, whose segmentation is a default.

Narrow both, and pin the boundary with a test, since the claim is
falsifiable prose.
Swapping is_suffix_lenient into _is_post_nominal still failed zero
tests. The previous round closed that with a docstring clause -- but
the clause NAMES the discriminating input under the default lexicon,
and when a comment can name the input, the input belongs in a test.

Measured, default lexicon:

    田中さん V.   strict  given 田中さん, family 'V.'   (no peel)
    田中さん V.   lenient given 田中, middle さん, family 'V.'
    田中さん II   strict  family 田中, suffix 'さん, II'
    田中さん II   lenient identical

The V. row is 1.4.0 parity (first 田中さん / last 'V.'), so it lands as
a parity row; the II row is a #308 deviation and is classified as one.
Together they are the pin: only the strict/lenient choice separates
them, and a future "unify the two suffix predicates" refactor would
have changed a user-visible parse silently.

corpus_cjk.jsonl regenerated (it derives from the case table).
test_ja_does_not_divide_a_spaced_surname_under_an_honorific asserted
that 佐藤 氏 keeps family 佐藤, but nothing in it showed the pack WOULD
divide 佐藤 otherwise -- it passed vacuously for any reason the
segmenter went unconsulted, and the contrast is what the doc sentence
it supports rests on.

Assert the bare division too (佐 + 藤, 田 + 中, 鈴 + 木, 中 + 村), and
add 中村 教授 so the four cases the prose now cites as the measured
price of this rule are the four the test runs.

The comment's "the writer drew this boundary: 佐藤 is a unit" goes
with them, for the reason the previous commit gives.
- _script_segment's module header Reads: line omitted the suffix
  vocabulary the stage now reads through _is_post_nominal ->
  _vocab.is_suffix_strict; _segment.py sets the convention for naming
  it. Produces: likewise omitted the peel's _PEELED_TAG.
- The same docstring's gate list ("gated by the FAMILY comma and by
  间隔号 but NOT by segment_scripts") read as exhaustive and omitted
  the ASCII bail -- the gate that actually decides a caller-configured
  LATIN tail. The bail's own comment had it right; the header had not
  caught up.
- config/suffixes.py said "Two more are in NEITHER set" and then named
  three strings (君, 선생, 교수). The list above it counts entries.
- AGENTS.md's shim-transformation roster was one short: maiden_delimiters
  minus nickname_delimiters carries the same v1-reachability comment
  and is pinned by test_snapshot_overlap_keeps_v1_nickname_precedence.
  Verified: with parens in both v1 buckets, 1.4.0 and HEAD both parse
  nickname, while Policy alone gives maiden. It sits on the Policy half
  of _snapshot(), which is why a Lexicon-only sweep missed it.
- test_properties.py's "neither derivation guarantees a fire" reads as
  "sometimes fires". Measured over the 250 examples: peel 2, surname
  half 0. Give the number and the structural reason -- a non-hangul
  surname makes `w + "민준"` a mixed-script token whose
  effective_script is None, so it is never an activated site; the one
  hangul token drawn ('남궁민준') landed in an example whose policy had
  HANGUL out of segment_scripts.
@derek73 derek73 self-assigned this Jul 31, 2026
@derek73 derek73 added this to the v2.1 milestone Jul 31, 2026
derek73 and others added 6 commits July 31, 2026 12:09
The peel called _split and then rebuilt the token tuple slice-wise to
set one tag on the piece _split had just created, re-deriving where its
own tail landed after handing the index arithmetic off -- an idiom no
other stage uses. _split now takes an optional tail_tag and applies it
to the last piece, which is the tail by construction for the one caller
that passes one. The surname path passes nothing and the default leaves
it unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
_LEX_TAILS re-declared _LEX's six-surname frozenset and its "jr" suffix
word verbatim three lines below it, so an edit to _LEX -- whose comment
explains why 欧/欧阳 and 남/남궁 are there -- would silently miss the
peel tests that exercise the same surname half. Building it with add()
produces an identical lexicon and keeps one declaration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
test_default_lexicon_honorific_tails_are_all_suffix_words justified
itself as closing a python -O gap in config/suffixes.py's import-time
assert, but Lexicon.__post_init__ enforces the same invariant with a
raise, which -O does not strip: Lexicon.default() can never construct a
violating instance, so the assert line is unreachable as a failure and
the constructor on the line above would raise first. Neither sibling
subset field has such a test either; the shared coverage is the
parametrized test_subset_error_names_the_fix. The useful half of the
justification -- that the runtime guard, unlike the config assert,
survives -O -- moves to the honorific_tails row in _SUBSET_FIELDS.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every other glued-set exclusion has a case row holding it -- 氏 by
zh_glued_shi_stays, 博士 by ja_glued_degree_stays, 君 by
zh_glued_jun_stays, 양 by ko_suffix_matching_is_whole_token -- while
殿, which has the longest argument behind it, had none: adding it back
to GLUED_HONORIFICS passed the entire suite. The discriminating input
is the lone surname 鵜殿 (Udono), which a peeled 殿 would report as
family 鵜 plus suffix 殿; a two-token 真殿 太郎 puts the site scan on
太郎 and never consults the exclusion. Verified by adding 殿 back: the
new row and its facade twin are the only tests that then fail.
Classified fix(#271) like its neighbours -- 1.4.0 gives first 鵜殿 and
no last, so it is the CJK order flip that moves the field.

corpus_cjk.jsonl regenerated; differential stays 0 unexplained.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every unit in _SHAPES was pure ASCII, so script_segment returned at its
isascii() bail before either the #308 peel or the #271 surname site ran:
the file's own docstring warns the absolute tests are structurally blind
to a complexity regression, and for this stage the scaling test was
blind too. A repeated 씨 walks the peel's site scan-back over the whole
run -- every token is a post-nominal -- and measures 4.0-4.3 at base
800, inside the clean column the ASCII shapes already occupy, so
_MAX_RATIO is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The peel costs a couple of microseconds per non-ASCII parse and is not
gated on segment_scripts -- deliberate, and the prose already says so,
but a performance-sensitive caller reaches for that field first.
Emptying honorific_tails hits the peel's first-line guard and recovers
the cost; customize.rst already documents that removal as the
BEHAVIORAL off-switch, so one clause there covers the other reason to
reach for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@derek73
derek73 merged commit e3ddf2e into master Jul 31, 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.

Glued CJK honorifics are absorbed into the name (山田太郎様 → family 山田太郎様)

1 participant