Skip to content

Commit 15bf4e3

Browse files
derek73claude
andcommitted
docs(mechanisms,cases,customize,differential): the fork criterion, measured
The VOCABULARY-EXERCISES-FORKS entry named four forks for maiden_markers. Measured, the set has none: it is read at exactly two sites, both plain whole-token membership after _normalize, and all sixteen members collapse to one structural class across bare, bracketed and lone-bracketed shapes with the marker text normalised out. Two of the four named forks were not parser behavior at all -- the substring split is _carries, a test guard that deliberately asks wider, and edge-period normalization is _normalize, which discriminates spellings across every vocabulary rather than members of this one. That makes the entry's argument stronger, and it now says so: rows on this set earn their place on clause SHAPE, never on which member appeared. The unit is the INPUT, matching cases.py's wording rather than the entry's old "one member and not another"; a row plainly earns its place with no member fork behind it. Three repairs beyond that. The delimiter enumeration claimed exhaustiveness and missed WORD_INTERNAL_DELIMITERS, which splits the same-character class in two -- 'Jane Smith"' reports UNBALANCED_DELIMITER and "Jane Smith'" reports nothing -- while listing ASCII against fullwidth, which is not a fork: swapping the pair across all 45 corpus names holding a paren changes no field and no ambiguity kind. The entry blurred the line Derek drew, so it states it: a delimiter pair the library SHIPS is caller-visible behavior and deleting one reddens three tests, while vocabulary a caller configures is data. And a row may earn its place a second way, with no fork behind it, by pinning a name a release note advertises by name. Its counts are gone rather than corrected. They were present-tense prose over a drifting wordlist, which root AGENTS.md forbids, this PR moved 17 to 16 by removing roz, and the descriptive figure was wrong anyway -- five members carry rows, so eleven have none. Two merge blockers. A cases.py note carried "leaves the entire suite green" beside the correcting sentence that says the suite catches it; the previous commit appended the truth and left the falsehood standing. And neither recompute one-liner ran as pasted: both bodies were indented inside the string handed to python -c, so both raised IndentationError. I had run them flush-left in my own shell, which is the inert-measurement shape they exist to prevent. Both are dedented, carry a note saying why, and were re-run by reconstructing them from the source and pasting. The #445 marker went on the bracketed rules only. The bare spelling is claimed at 1.4.0 by fix(#274), which also declares `family`, so a "keep the family" fix would land inside it with the gate green -- the same absorption on the same field at the same baseline. Marked there too, with the names to re-measure listed. Also: three ledger regexes gained (?i) in the previous commit without being mentioned, a real widening (the class now matches NEE and NÉE) bounded by _Claim's digest; customize.rst said suffix-shaped content "is read as a suffix" when S1 drops the brackets and parses the content as if written bare; _policy.py's autodoc'd docstring never got that clarification; and a 63/9 count is replaced by its recompute command, since 9 does not reproduce under its own definition. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 46f58ee commit 15bf4e3

9 files changed

Lines changed: 131 additions & 66 deletions

File tree

docs/customize.rst

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -298,11 +298,14 @@ listed below.
298298
no configuration since 2.2 is a clause that opens with a marker
299299
word AND has a word after it: ``"Jane Smith (née Jones)"`` reads
300300
maiden ``Jones`` whatever pair encloses it, unless the content is
301-
suffix-shaped, which is read as a suffix ahead of both. A marker
302-
word opening the enclosed content is dropped from the value
303-
either way, but only where that content holds more than one
304-
*token* — the same reason a lone ``"(Nee)"`` is a maiden name
305-
rather than a marker. Tokens, not words: a marker written
301+
suffix-shaped, which is taken ahead of both: the brackets are
302+
dropped and the content parses as if written bare, so
303+
``"Jane Smith (née Jr.)"`` gives family ``née``, suffix ``Jr.``
304+
rather than a suffix of the whole clause. A marker word opening
305+
the enclosed content is dropped from the value either way, but
306+
only where that content holds more than one *token* — the same
307+
reason a lone ``"(Nee)"`` listed here keeps ``Nee`` as the
308+
maiden value rather than reading it as a marker. Tokens, not words: a marker written
306309
against the name it marks is one token with them, so
307310
``"山田花子(旧姓佐藤)"`` keeps its ``旧姓``. Defaults to empty —
308311
see the routing example below.
@@ -506,8 +509,11 @@ Nicknames, maiden names, and brackets
506509
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
507510

508511
A delimiter pair carries no meaning of its own, so what a clause reads
509-
as is settled in steps. Suffix-shaped content is taken first and reads
510-
as a suffix. Then the content is asked whether it announces itself: a
512+
as is settled in steps. Suffix-shaped content is taken first: the
513+
brackets are dropped and what was inside parses as if it had been
514+
written bare, which is not the same as the clause becoming the suffix
515+
(``"Jane Smith (née Jr.)"`` gives family ``née``, suffix ``Jr.``).
516+
Then the content is asked whether it announces itself: a
511517
clause opening with a recognized maiden marker and carrying a word
512518
after it is a maiden name whatever encloses it, and needs nothing
513519
configured. Only for what is left — markerless content, and a lone

docs/design/mechanisms.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,21 @@ Contract statement. A user-supplied pattern is rejected as over-matching by prob
141141

142142
### VOCABULARY-EXERCISES-FORKS — pin the branch, not the wordlist
143143

144-
Contract statement. A vocabulary set is data; the code that reads it is what has behavior, so a case row earns its place by exercising a FORK — a branch the parser takes for one member and not another — never by covering a member. Sixteen maiden markers are not sixteen behaviors: født, fødd and född are one class, and a row for each pins nothing the first does not. It follows that "N of M entries have no test" is not a finding; it reads as one because it carries a number. The finding is "this fork has no test", and the forks are findable by reading the code that consults the set and enumerating the branches it takes — for maiden markers, the substring-versus-token split (ASCII against not), the edge-period normalization that makes geb. and geb one word, the native-script whole-token case that cannot collide with Latin text, and a member borne as an ordinary name (decisions.md#vocabulary-collisions). Four classes, four rows; the other twelve entries are data. The same reduction applies to the delimiter pairs, where the forks are same-character against distinct-character open and close (N2) and ASCII against fullwidth, not eleven pairs. Coverage measured against a wordlist grows with every entry added and narrows nothing, which is the tell: a criterion that inflates with the data is measuring the data.
144+
Contract statement. A vocabulary set a caller configures is data; the code that reads it is what has behavior, so a case row earns its place by exercising a FORK — a branch the parser takes for one INPUT and not another — never by covering one more member. The unit is the input, not the member, because most forks are keyed on the shape of the surrounding name rather than on which word appeared. Find them by reading the sites that consult the set and enumerating the branches they take, and expect that enumeration to come back empty: `maiden_markers` is read at exactly two sites (`_classify.py`'s tagging and `_extract.py`'s `_maiden_marked`), both plain whole-token membership after `_normalize`, and every shipped member parses to one structural class in bare, bracketed and lone-bracketed shapes with the marker text normalised out — zero member forks. What earns the maiden rows is clause SHAPE: marker first against marker later, a word after the marker against none, a marker a separator divides from the name against one glued to it. Two adjacent things are NOT forks of this set and do not want rows: the substring-versus-token split is `_carries` in test_ledger_guards.py, a test guard deliberately asking a wider question than the parser (tools/differential/README.md says so), and edge-period normalization lives in `_lexicon._normalize`, applying to every token of every vocabulary, so it discriminates spellings rather than members. Only "a member borne as an ordinary name" is member-keyed (decisions.md#vocabulary-collisions), and it has its own rows. The reduction does NOT carry across to delimiter pairs, and the difference is the line to hold: a pair the library SHIPS is caller-visible behavior, and vocabulary a caller configures is data — deleting `("«", "»")` from the defaults reddens three tests, so cases.py's one-row-per-shipped-pair convention is load-bearing and this criterion is not permission to delete those rows. Within the pairs the code does fork, on whether open and close are the same character (N2) and again inside that class, where `WORD_INTERNAL_DELIMITERS` carves the apostrophe out: `Jane Smith"` reports UNBALANCED_DELIMITER and `Jane Smith'` reports nothing. ASCII against fullwidth is not one of them — swapping `( )` for `( )` across every corpus name holding a paren changes no field and no ambiguity kind. A row may also earn its place a second way, with no fork behind it: pinning a name a release note advertises BY NAME, which is a promise to a reader rather than a branch in the parser. It follows that "N of M entries have no test" is not a finding; it reads as one because it carries a number, and it inflates with every entry added, which is the tell that it measures the data. The finding is "this fork has no test". State the argument without present-tense counts over a drifting wordlist — see docs/design/AGENTS.md — and where a count is genuinely wanted, ship the one-liner instead:
145+
146+
```
147+
uv run python -c "
148+
from nameparser import parse
149+
from nameparser.config.maiden_markers import MAIDEN_MARKERS
150+
shapes = ['Jane Smith {m} Jones', 'Jane Smith ({m} Jones)', 'Jane Smith ({m})']
151+
cls = {}
152+
for m in sorted(MAIDEN_MARKERS):
153+
sig = tuple(tuple((f, (getattr(parse(s.format(m=m)), f) or '').replace(m, '<M>'))
154+
for f in ('given', 'middle', 'family', 'suffix', 'nickname', 'maiden'))
155+
for s in shapes)
156+
cls.setdefault(sig, []).append(m)
157+
print(len(MAIDEN_MARKERS), 'members ->', len(cls), 'structural classes')"
158+
```
145159

146160
### FORCE-A-DECISION-TABLE — no silent defaults on growth
147161

nameparser/_pipeline/_extract.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -234,10 +234,10 @@ def extract_delimited(state: ParseState) -> ParseState:
234234
# role that is not NICKNAME is already MAIDEN and the
235235
# assignment would be a no-op either way. It is kept
236236
# for the day `order` gains a third bucket, when it
237-
# becomes the
238-
# difference between M3 claiming that bucket's clauses
239-
# and leaving them. Measured 2026-08-26: dropping it
240-
# leaves the suite and all three gates green.
237+
# becomes the difference between M3 claiming that
238+
# bucket's clauses and leaving them. Measured
239+
# 2026-08-26: dropping it leaves the suite and all
240+
# three gates green.
241241
if (role is Role.NICKNAME and _maiden_marked(
242242
text[inner.start:inner.end], state.lexicon)):
243243
role = Role.MAIDEN

nameparser/_policy.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -623,9 +623,17 @@ class Policy:
623623
#: field instead; a pair listed here is dropped from the effective
624624
#: nickname set (maiden wins, see __post_init__), so
625625
#: maiden_delimiters=frozenset({("(", ")")}) is the whole recipe (#274).
626-
#: A maiden_markers word opening the enclosed content is dropped
627-
#: from the value, but only where that content holds more than one
628-
#: token: a lone "(Nee)" is a maiden NAME, not a marker (#329).
626+
#: Set this for a clause that says nothing about itself, which is
627+
#: two kinds and not one: content with no marker word in it, and a
628+
#: LONE marker word. Since #335 a clause that opens with a marker
629+
#: word AND has a word after it reads as the maiden name whatever
630+
#: pair encloses it, so "Jane Smith (née Jones)" needs no
631+
#: configuration -- unless the content is suffix-shaped, which is
632+
#: taken first, the brackets dropped and the content read as if
633+
#: written bare. A maiden_markers word opening the enclosed
634+
#: content is dropped from the value, but only where that content
635+
#: holds more than one token: a lone "(Nee)" is a maiden NAME,
636+
#: not a marker (#329).
629637
maiden_delimiters: frozenset[tuple[str, str]] = frozenset()
630638
#: Additional separators that split suffix groups (e.g. " - " for
631639
#: "Jane Smith, RN - CRNA"). Additive only: the comma always

tests/v2/cases.py

Lines changed: 31 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,11 @@
2424
a policy that changes the answer. A row demonstrating one more member
2525
of a vocabulary set pins nothing its other members do not, and one row
2626
per entry grows this table without narrowing what can break
27-
(mechanisms.md#VOCABULARY-EXERCISES-FORKS).
27+
(mechanisms.md#VOCABULARY-EXERCISES-FORKS -- a restatement, and
28+
nothing checks it against the entry: test_doc_citations verifies an
29+
excerpt only where the reference is followed by a colon and a quoted
30+
span, which running prose like this one is not. Read the entry, not
31+
this paragraph, if the two ever disagree).
2832
"""
2933
from __future__ import annotations
3034

@@ -1383,10 +1387,14 @@ def __post_init__(self) -> None:
13831387
"The empty family is the bare path's, not this "
13841388
"change's: 'Smith née Jones' reads given 'Smith', "
13851389
"family '' on 2.1.0 too, and 1.4.0 read first 'Smith' / "
1386-
"middle 'née' / last 'Jones' -- the marker as a middle "
1387-
"name, which is why the sibling fix(#410) rule at 1.4.0 "
1388-
"declares `middle`. The given side has agreed since "
1389-
"1.4.0, "
1390+
"middle 'née' / last 'Jones' -- the marker as a "
1391+
"middle name. The parallel evidence at 1.4.0 is not "
1392+
"the fix(#410) maiden-flavor rule, which is keyed on "
1393+
"the TITLED 'Dr. Smith née Jones', but fix(#274), "
1394+
"which claims the bare one-word names 'Janey née "
1395+
"Jones' and 'abd née Jones' and declares `family` for "
1396+
"exactly the reason this row records. The given side "
1397+
"has agreed since 1.4.0, "
13901398
"the emptying is 2.x's and is tracked separately as the "
13911399
"#410 analogue. What #335 moves is only the bracketed "
13921400
"spelling: 1.4.0, 2.0.0 and 2.1.0 all read it family "
@@ -1405,11 +1413,16 @@ def __post_init__(self) -> None:
14051413
"the parenthesis, and the fullwidth pair below -- and "
14061414
"the other EIGHT have no row. Measured 2026-08-26 by "
14071415
"disabling the swap one pair at a time: those three "
1408-
"redden and the eight do not. So this row and the "
1409-
"fullwidth one fence two ends of the set rather than "
1410-
"the whole of it; a set-level roster is what would "
1411-
"cover the rest. 1.4.0 and 2.1.0 both read nickname "
1412-
"'née Jones'"),
1416+
"redden and the eight do not. That is a count over a "
1417+
"wordlist, so read it the way "
1418+
"mechanisms.md#VOCABULARY-EXERCISES-FORKS says to: the "
1419+
"eight are not eight gaps, since the pairs fork on "
1420+
"whether open and close are the same character and on "
1421+
"the apostrophe carve-out inside that, not on which "
1422+
"pair. This row is here because a quote pair is the "
1423+
"same-character branch, and because the release note "
1424+
"advertises the spelling. 1.4.0 and 2.1.0 both read "
1425+
"nickname 'née Jones'"),
14131426
Case("maiden_marked_clause_takes_the_suffix_reading_from_s1",
14141427
"Jane Smith (née Jr.)",
14151428
{"given": "Jane", "middle": "Smith", "family": "née",
@@ -1678,15 +1691,14 @@ def __post_init__(self) -> None:
16781691
classification="feat(#273) + fix(#271) + fix(#335)",
16791692
notes="the row above without its policy, and the one "
16801693
"that fences M3 across the delimiter SET rather "
1681-
"than at the parenthesis: gating the swap to '(' "
1682-
"and the double quote leaves the entire suite "
1683-
"green and even the 2.1.0 gate green, its "
1684-
"fix(#335) rule quietly falling from six names to "
1685-
"five, and the 1.4.0 and 2.0.0 gates going red while "
1686-
"2.1.0 stays green (measured 2026-08-26; the 2.0.0 "
1687-
"ledger gives this name a four-field rule of its own, "
1688-
"which is what catches it there). The suite catches it "
1689-
"too, at this row. The fullwidth pair is the "
1694+
"than at the parenthesis. Measured 2026-08-26 by "
1695+
"gating the swap to '(' and the double quote: this row "
1696+
"and its facade twin are the only two failures in the "
1697+
"suite, and of the gates only 1.4.0 and 2.0.0 redden "
1698+
"-- 2.1.0 stays green with its fix(#335) rule quietly "
1699+
"falling from six names to five, while the 2.0.0 "
1700+
"ledger catches it on the four-field rule it gives "
1701+
"this name. The fullwidth pair is the "
16901702
"one the maiden_markers docstring and the 2.2 "
16911703
"release note both advertise as newly working "
16921704
"without configuration, so it is the one that "

tests/v2/test_ledger_guards.py

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1124,19 +1124,19 @@ def _carries(name: str, vocabulary: frozenset[str]) -> bool:
11241124
does not rest on a substring. Both figures quantify over the
11251125
corpus and go stale on any row added to it, so recount rather than
11261126
adjust:
1127-
uv run python -c "
1128-
import glob, json
1129-
from nameparser import DEFAULT_NICKNAME_DELIMITERS as D
1130-
from nameparser.config.maiden_markers import MAIDEN_MARKERS as V
1131-
from nameparser._lexicon import _normalize
1132-
names = {json.loads(l) for f in glob.glob('tools/differential/corpus*.jsonl')
1133-
for l in open(f, encoding='utf-8') if l.strip()}
1134-
strip = ''.join({c for p in D for c in p})
1135-
sub = lambda n: any(e in n for e in V if not e.isascii())
1136-
print(sum(not {_normalize(t.strip(strip)) for t in n.split()} & V
1137-
and sub(n) for n in names),
1138-
sum(not {_normalize(t) for t in n.split()} & V
1139-
and sub(n) for n in names))"
1127+
The body must sit flush left: `python -c` compiles it as a module,
1128+
so an indented first line raises IndentationError on paste.
1129+
1130+
uv run python -c "
1131+
import glob, json
1132+
from nameparser import DEFAULT_NICKNAME_DELIMITERS as D
1133+
from nameparser.config.maiden_markers import MAIDEN_MARKERS as V
1134+
from nameparser._lexicon import _normalize
1135+
names = {json.loads(l) for f in glob.glob('tools/differential/corpus*.jsonl') for l in open(f, encoding='utf-8') if l.strip()}
1136+
strip = ''.join({c for p in D for c in p})
1137+
sub = lambda n: any(e in n for e in V if not e.isascii())
1138+
print(sum(not {_normalize(t.strip(strip)) for t in n.split()} & V and sub(n) for n in names),
1139+
sum(not {_normalize(t) for t in n.split()} & V and sub(n) for n in names))"
11401140
Tighten this before admitting a vocabulary whose short non-ASCII
11411141
entries occur inside ordinary names.
11421142

tests/v2/test_parser.py

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1330,19 +1330,19 @@ def _clause_free_latin_corpus_names() -> list[str]:
13301330
# Every count in this comment quantifies over the corpus, so one
13311331
# added corpus row falsifies it silently. Recount rather than
13321332
# adjust:
1333-
# uv run python -c "
1334-
# import re, sys; sys.path.insert(0, 'tests')
1335-
# from nameparser import DEFAULT_NICKNAME_DELIMITERS as D
1336-
# from nameparser.config.maiden_markers import MAIDEN_MARKERS as M
1337-
# from v2._differential_fixtures import _CORPUS_NAMES
1338-
# base = [n for n in _CORPUS_NAMES
1339-
# if re.match(r'^[\x00-\u024f]*$', n) and ',' not in n]
1340-
# strip = ''.join({c for p in D for c in p}) + '.'
1341-
# keep = lambda f: [n for n in base
1342-
# if not any(f(w) in M for w in n.split())]
1343-
# old = keep(lambda w: w.lower().rstrip('.'))
1344-
# new = keep(lambda w: w.lower().strip(strip))
1345-
# print(len(old), len(new), sorted(set(old) - set(new)))"
1333+
# The body must sit flush left: `python -c` compiles it as a module,
1334+
# so an indented first line raises IndentationError on paste.
1335+
# uv run python -c "
1336+
# import re, sys; sys.path.insert(0, 'tests')
1337+
# from nameparser import DEFAULT_NICKNAME_DELIMITERS as D
1338+
# from nameparser.config.maiden_markers import MAIDEN_MARKERS as M
1339+
# from v2._differential_fixtures import _CORPUS_NAMES
1340+
# base = [n for n in _CORPUS_NAMES if re.match(r'^[\x00-\u024f]*$', n) and ',' not in n]
1341+
# strip = ''.join({c for p in D for c in p}) + '.'
1342+
# keep = lambda f: [n for n in base if not any(f(w) in M for w in n.split())]
1343+
# old = keep(lambda w: w.lower().rstrip('.'))
1344+
# new = keep(lambda w: w.lower().strip(strip))
1345+
# print(len(old), len(new), sorted(set(old) - set(new)))"
13461346
#
13471347
# Delimiter characters come from the shipped set rather than a
13481348
# literal, so a pair added there cannot quietly reopen this.

tools/differential/README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -424,9 +424,9 @@ Those two numbers count WHOLE TOKENS, delimiters stripped. The strip
424424
is what earns exactly one of the four: `nee`, which appears in the
425425
corpora only inside brackets -- `Jane Smith (Nee)` and
426426
`Jane Smith (Nee) (Jones)` -- where the token carries them until they
427-
come off. `née` needs no strip, appearing
428-
bare in thirty-odd names, and `` is not counted at all -- it occurs
429-
only as a substring of `née`, never as a token. The convention matters because the neighbouring guard
427+
come off. `née` needs no strip, appearing bare in many names, and ``
428+
is not counted at all: it occurs only as a substring of `née`, never
429+
as a token. The convention matters because the neighbouring guard
430430
`tests/v2/test_ledger_guards.py::_carries` deliberately asks a wider
431431
question -- it also matches a non-ASCII entry anywhere inside a name,
432432
since 旧姓 is written flush against the name it marks -- and under that

0 commit comments

Comments
 (0)