Skip to content

fix(post_rules): H1 counts name words, not annotations (#410) - #444

Merged
derek73 merged 7 commits into
masterfrom
fix/410-h1-name-words
Aug 26, 2026
Merged

fix(post_rules): H1 counts name words, not annotations (#410)#444
derek73 merged 7 commits into
masterfrom
fix/410-h1-name-words

Conversation

@derek73

@derek73 derek73 commented Aug 26, 2026

Copy link
Copy Markdown
Owner

Closes #410.

Dr. Smith reads family Smith; Dr. Smith née Jones read given Smith with no family. H1's "and nothing else" guard counted a suffix, a nickname or a maiden name as a further name word, and all three suppressed the rule — one term in one guard, so the term went rather than the maiden role being special-cased.

input 1.4.0 master after
Dr. Smith née Jones first='Smith' middle='née' last='Jones' given='Smith' family='' family='Smith' maiden='Jones'
Dr. Smith PhD first='Smith' last='PhD' given='Smith' family='' family='Smith' suffix='PhD'
Dr. "Smitty" Smith first='Smith' last='' given='Smith' family='' family='Smith'
Sir John née Jones first='John' middle='née' given='John' family='' unchanged — given-name titles are carved out

Five corpus names change reading. Four arrive as new diffs and are classified in all three ledgers. The fifth, Freiherr von Richthofen V, was already claimed by the fix(#424) rule: this change narrows its diff from {given, family, suffix} to {family, suffix}, and since classify() accepts a rule whose declared fields are a superset of the observed diff, a shrinking diff kept matching and no run ever named the movement. That declaration is now narrowed to match, so the next given regression on that name surfaces instead of being absorbed.

At the 1.4.0 baseline three of the four were being absorbed by the fields-only fix(suffix-routing) catch-all (heading 14 → 17), invisible to _CORPUS_CLAIMS because that constant records the whole corpus for a rule with no name_regex. They have their own rules now, as #372's names got one off the same catch-all.

N3 moves without changing: 'Smitty' Dr. Jones declines its one-piece count as always, and H1 then names the family. Its Accepted clause dropped a half that is no longer true and gained two executable examples, including 'Smitty' Sir John, where a given-name title means no family is named after all.

H1 also gains the decisions.md entry it never had, including one thing left unfixed on purpose: the statement says "exactly one name word", but the guard tests which roles are unoccupied and retags every given token, so Freiherr de V Jr yields family de V. No corpus name or example reaches it; recorded rather than repaired, since this change is about which roles disqualify the rule, not how it counts.

The v1 suite already shipped the correct reading for the nickname shape as a strict xfail (tests/test_nicknames.py::test_nickname_and_last_name_with_title); it passes now and the marker is gone.

Review rounds. Six in total (spec compliance, design-docs ×2, full-branch code review, then comment / test-coverage / code review on the open PR). No Critical code findings at any point — the behavior change reproduces as exactly the intended givenfamily move across 16,000 parses and six policies. What they found was prose, and mutation gaps: four independent guard mutations survived all 5,819 tests, the worst being that H1 could be made to decline on every comma-bearing name unnoticed, because the one broad property test filters commas out of its corpus. Five cases.py rows now pin those shapes — Dr. King, Jr. among them, which 1.4.0 also read with an empty family — and each kills exactly one mutation.

Verification: suite 5844 passed; compare.py exits 0 at 1.4.0, 2.0.0 and 2.1.0; mypy and ruff clean. Three review rounds (spec compliance, design-docs ×2, full-branch code review) found 16 issues, all in prose, all fixed — the code survived a 16,000-parse sweep across six policies with every diff being exactly the intended givenfamily move.

🤖 Generated with Claude Code

derek73 and others added 4 commits August 25, 2026 21:53
A title followed by one name word names the family, but the guard
declined whenever a suffix, a nickname or a maiden name stood beside
it, so "Dr. Smith née Jones" read given 'Smith' with no family where
"Dr. Smith" reads family 'Smith'. None of the three is a name word:
each stands beside the name rather than in it.

The rule and its implementation move together -- H1 said "and nothing
else", which the code read as "and no annotation either", and
test_doc_citations couples the two. H1 now says name words, carries
the maiden example that showed the gap, and names the rules it meets
there; M2 points back at H1, which #410 asked for. corpus_rules.jsonl
is generated from the doc's example lines, so it regenerates here to
take the new ones in, and two recorded ledger effects move by exactly
those names.

The v1 suite already recorded the correct reading for the nickname
half as a strict xfail; it passes now and the marker goes. The
corpus-wide maiden-clause invariant loses the shape it was skipping
and gains M2's own boundary in its place.

N3 moves with it: "'Smitty' Dr. Jones" declines N3's one-piece count
as it always did, and H1 now names the family behind it, so the rule
and its decision entry stop claiming an empty family there.

Closes #410

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One rule each at 2.0.0 and 2.1.0, keyed on the names by literal rather
than on a fields-only shape: "a title, one name word, and an
annotation" is a parse tree, not a string, and a fields-only rule
would classify every future given/family swap in the corpus.

1.4.0 takes TWO rules. Three of the names move given/family there as
they do at the later baselines, but 'Dr. Smith née Jones' moves four
fields -- that baseline has no maiden support, so the marker sat in
`middle` and the birth surname in `family` -- and one rule carrying
the union of those fields would let the other three move a middle or
a maiden name unnoticed.

At the 1.4.0 baseline this also takes three of them off
fix(suffix-routing), which was absorbing them silently -- its heading
went 14 -> 17 while _CORPUS_CLAIMS, recording the whole corpus for a
rule with no name_regex, could not see the growth. Same surgery as
#372's names, on the same catch-all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
H1 gains the decision entry it never had -- its rationale is doing
normative work now, so the argument needs a durable home and a
history: pointer like every other rule. #410 leaves Open (M2), and
the two places that cited its lone-residual shape as a standing
boundary stop describing behavior that no longer exists.

The entry is explicit that the rationale is stated for H1 alone: N3
counts a suffix the other way, and which reading is right for a
nickname-led name is not decided here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five corpus names change reading, not four: `Freiherr von Richthofen
V` moves too, and it never surfaced because the existing fix(#424)
rule's fields are a superset of its narrowed diff -- a real movement
absorbed by a green gate. It is also the suffix flavor, which three
ledger comments claimed no corpus carries.

N3's new sentence swept in the given-name titles it should have
carved out (`'Smitty' Sir John` names no family), the release log
contradicted itself inside one unreleased section, and H1's known
gap -- the statement counts name words, the guard tests which roles
are unoccupied -- is now on record rather than implied.

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

codecov Bot commented Aug 26, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.60%. Comparing base (b244c5a) to head (507a8f2).

Additional details and impacted files
@@            Coverage Diff             @@
##           master     #444      +/-   ##
==========================================
- Coverage   98.60%   98.60%   -0.01%     
==========================================
  Files          45       45              
  Lines        3091     3090       -1     
==========================================
- Hits         3048     3047       -1     
  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 and others added 3 commits August 25, 2026 23:35
…ew round

The skip predicate is reverted: widening it with `nickname` was
backwards. A nickname-only name belongs in the skip class, not out of
it -- '(Bud) née Jones' reads given 'née', family 'Jones' and no
maiden, M2 having left the marker a word -- so the widening would have
sent such a name to an assertion it must fail. `maiden` was worse than
wrong: the parametrization filters out every name carrying a marker,
so the term could never fire. The nickname case is documented in the
docstring, where it belongs, instead.

fix(#424)'s `fields` is narrowed to drop `given`, closing the absorber
the previous commit only described. Since #410 that name's diff is
{family, suffix}; the declaration kept claiming {given, family,
suffix}, and a subset match let the diff shrink underneath it without
a word from any gate. All three baselines stay at exit 0.

The rest is prose the reviewers caught: two ledger comments claimed
the suffix flavor appears in no corpus, the known-gap paragraph
counted tokens where it meant units, H1 was missing from the module
docstring's history list and the guard comment had no pointer to the
gap recorded against it, and the release log's "one of them reaches
the default name order" framing is retired rather than incremented --
nearly all of this cycle's fixes reach it. Two stale counts beside
machine-checked twins are recounted: 34 -> 46 and 47 -> 63 (with 11
-> 9 alongside it).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four independent mutations of the widened guard survived the whole
suite: declining on any comma, on a second maiden token, on a second
suffix token, and on a nickname and a suffix together. The rule fires
across far more shapes than the four single-annotation rows pinned it
at, and each mutation is a plausible mis-rewrite of it.

"Dr. King, Jr." is the one that matters most. It is the most ordinary
real-world shape the rule touches, and nothing in the suite could see
the class: the corpus-wide maiden-clause property test filters commas
out of its parametrization, and "Smith, Dr." takes its family from the
comma rule rather than from H1. 1.4.0 had the same empty family, so
the row records a v1 bug fixed.

The other four cover the arities and combinations the single-
annotation rows cannot reach, including the given-name carve-out at a
two-word maiden clause -- the rule now says "whatever maiden name
stands beside it", and the "whatever" was asserted at one word only.

Two smaller repairs: tests/test_titles.py lost its assertion that
`first` is empty when the expectation moved to `last`, and the
carve-out row's note claimed `given` without saying that this is a
default-order claim -- under either family-first order that name reads
family 'John', H1 being a no-op once assign has placed the word.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ragraph form

The #410 entries came in hard-wrapped at ~70 columns; every other
entry in this file is one paragraph on one line, its neighbours
running 728 and 5,211 characters. Pure reflow: no rewording, no
re-paragraphing, and the doc parsers see the same text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@derek73
derek73 merged commit 2c05dbb into master Aug 26, 2026
11 checks passed
github-actions Bot pushed a commit to SavantEnvs/python-nameparser that referenced this pull request Aug 29, 2026
Six corpus names change reading under rules.md#M3 against 2.1.0 and
2.0.0, five against 1.4.0. One rule per ledger, keyed on the names by
literal: the shape is "a bracketed clause whose first word is a
marker", which is a question about the parse and not about the string,
so a fields-only rule standing in for it would pre-excuse every future
nickname/maiden confusion in the corpus.

`fields` is exactly what each baseline's diff moves. Against 2.1.0 all
six move {nickname, maiden}. Against 2.0.0 the five Latin names move
that pair while the CJK name also flips given/family, so that ledger
gets two rules rather than one declaring the union -- a union would
have let a given/family regression on any Latin name pass unreported,
which is what PR derek73#444 found behind fix(derek73#424) and had to narrow.

Against 1.4.0 the CJK name is absent, and that is the interesting
half. Its diff there is now {given, middle, family, maiden}: 1.4 read
the fullwidth brackets as name text, so no nickname changes hands, and
fix(cjk-maiden-marker) already describes exactly that diff and takes
the name. fix(cjk-fullwidth-paren-nickname), whose only name it was,
is declared dormant rather than deleted -- the reasoning it carries
about its `fields` boundary is recorded nowhere else, and the
declaration is load bearing in reverse.

_carries() could not see the markers in question: it strips a token's
abbreviating period but not its bracket, so 'Anna Müller (geb.
Schmidt)' read as carrying no maiden vocabulary and the presence guard
refused a rule that was right. Stripping delimiter characters only
ever finds markers that are really there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
github-actions Bot pushed a commit to SavantEnvs/python-nameparser that referenced this pull request Aug 29, 2026
rules.md. The M Background gains Polish and the fact a marker need not
be one word. Three statements were wrong for a phrase and are fixed,
each with its citing code comment in this same commit
(test_doc_citations quotes them verbatim):

  M1  "a leading recognized marker word inside a multi-word clause
      being dropped; a one-word clause keeps its word" -- a phrase
      makes '(z domu)' a multi-word clause whose marker is NOT
      dropped. Now keyed on a word standing past the marker, which is
      what the code has always tested.
  M2  gains a sentence: a marker recognized only whole, its own first
      word standing alone being an ordinary name word.
  M3  "opens with a recognized marker word" -> "a recognized marker".

Four examples land with them and so enter the rules corpus. Two move:
'Maria Kowalska z domu Nowak' (middle/family/maiden) and 'Maria
Kowalska (z domu Nowak)' (nickname/maiden), at all three baselines,
each with its own ledger rule declaring exactly its own fields -- not
the union, which is derek73#444's post-mortem, and not an alternative inside
the fix(derek73#335) rule, which this is not: M3's reach alone leaves the
bracketed name a nickname, and the phrase entry is what moves it.
'Anna z Nowak' and 'Maria Kowalska (z domu)' are parity and move
nothing. Gates green at 2.1.0, 2.0.0 and 1.4.0.

Two guards had the same blind spot the change itself had, and both are
real: they asked "is any WORD of this name a marker", which no phrase
entry can answer yes to.

  tests/v2/test_parser.py's clause-free corpus filter let 'Maria
  Kowalska z domu Nowak' through, so the appended clause was not the
  only variable and the invariant failed. It asks the predicate now;
  the recount is in its comment (642 names, 632 after the delimiter
  strip, 629 once the predicate decides).
  test_ledger_guards._carries said the same name carries no maiden
  vocabulary, which would have refused the ledger rule above. It
  matches consecutive token RUNS up to the longest entry now, which
  is the single tokens themselves for any vocabulary of words, so
  every count in that module is unchanged.

Re-recorded, each because the corpus grew and not because a rule did:
fix(suffix-routing)'s whole-corpus claim (1080 -> 1084), the
parenthesized-clause exclusion (51 -> 53 captures, absorbed_by still
empty), and the two new derek73#434 claims.

decisions.md, under M2 and unwrapped: why a run predicate was rejected
(z and domu are markers in no vocabulary, so no run can be identified
before the lookup, where 'lt' and 'col' each are); that
given_name_titles is the storage precedent and not the matching one,
and that no shipped set held a multi-word entry before this; C-i
answered at the phrase level, with the roz removal as the contrast --
a phrase changes the unit that CLAIMS rather than passing the test
with the old one, and roz has no longer unit to move onto; the four
sites, the contract test, and the preserved whitespace/token
divergence with the row that pins it; the commit ordering, including
that filterwarnings = ["error"] is what makes the warning carve-out
load-bearing rather than tidy; and the measured 6.7% -> 2.2% cost with
its method.

Swept for the rest of the single-word claim rather than stopping at
the two files already fixed: docs/customize.rst (the caller-facing
statement, now naming both exceptions and how they differ),
rules.md's S Background, decisions.md's Excluded (multi-word) header,
_policy.maiden_delimiters' field doc, tools/differential/README.md's
vocabulary-coverage count and its recompute one-liner (4 of 16 -> 5 of
17, counting runs), and docs/design/AGENTS.md's derek73#291 anecdote, whose
premise was true when it was written.

Release log: what works, that the previously advised workaround was
wrong and what it produced, and the migration note to remove z and
domu if they were added separately. Every value in it measured on this
commit, on the 1.4.0 wheel and on the 2.1.0 wheel; the roz bullet's
"now 16 entries" became "this removal takes the default set to 16" so
the two bullets do not contradict inside one release.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
derek73 added a commit that referenced this pull request Sep 1, 2026
Derek: "could we change it to Jean" -- `de Mesnil` is French and `Juan`
is Spanish, so the pair read as fuzz rather than as a name. rules.md
already uses `Ménil Christophe de` for the same shape, so `Jean` brings
the odd one out into line.

Renamed everywhere rather than only in the docs he was reading, because
the alternative is user docs and contributor docs disagreeing about the
same example: rules.md (which GENERATES corpus_rules.jsonl), decisions.md,
customize.rst, the release note, cases.py, and four code comments.

Two expected VALUES had to move with the inputs, and neither was caught
by the rename itself -- `given="Juan"` is not the string `de Mesnil
Juan`, so a search-and-replace on the input leaves the assertion behind
and the example silently asserts the old answer. rules.md#P1's example
line and cases.py's family_comma_no_name_word_family_first row both
failed on exactly that, which is what those tests are for.

_CORPUS_CLAIMS records a digest per rule, so the two 1.4.0 rules holding
this name needed re-recording: names 280 -> 280 and roles unchanged in
both, only the digest moving, which is what a pure rename should look
like. A count that GREW would have meant something was absorbed.

Verified the way the #444 lesson asks -- not just that the gate is green
but that nothing MOVED under it: 1104 corpus names and 226 / 198 / 106
intentional diffs at 1.4.0 / 2.0.0 / 2.1.0, unexplained 0, the same 66 /
59 / 52 headings as before the rename, and the 1.4.0 per-heading counts
byte-identical. 6197 tests, 244 doctests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@derek73
derek73 deleted the fix/410-h1-name-words branch September 1, 2026 05:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Dr. Smith née Jones reports no family name — a maiden name suppresses the title-plus-one-word rule

1 participant