Skip to content

Pin the maiden rule to its vocabulary, and open the 2.1 ledger - #351

Merged
derek73 merged 10 commits into
masterfrom
claude/issue-350-maiden-pin
Aug 9, 2026
Merged

Pin the maiden rule to its vocabulary, and open the 2.1 ledger#351
derek73 merged 10 commits into
masterfrom
claude/issue-350-maiden-pin

Conversation

@derek73

@derek73 derek73 commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Closes #350, and clears release step 8, which was outstanding from 2.1.0.

Pin the maiden rule, drop born (#350)

expected_since_1.4.0.toml's fix(#274) rule hand-copies MAIDEN_MARKERS and nothing checked it. It offered born, which is in no config constant and never was, in v1 or v2 — traced to 1748493, the harness's first commit. So that alternative could not correspond to a real #274 change; what it could do is claim any name containing born with fields = ["maiden", "middle", "family"] and absorb a genuine regression. The rule matches Max Born.

The pin is deliberately not the honorific pin's set equality, because these members are regex fragments, not entries — n[ée]e covers two markers, geb\.? covers one, so there is no set to compare:

  • Hard assertion: every alternative must match at least one entry the vocabulary ships. This is the over-claiming direction, and the one that absorbs regressions.
  • Recorded, not equated: the covered set (4 of 17). Forcing equality would widen the rule to markers no corpus name exercises — the README notes 3 of 17 appear anywhere — and 旧姓 already has its own rule in the 2.0 ledger, so folding it in would make two rules answer for one diff. Recording still catches removal: drop an entry a member covers and the set shrinks.

Dropping born changes no classification — no name in any of the three corpora contains it, which is also why it was never caught.

On promoting it instead: English is absent from MAIDEN_MARKERS on purpose, since English borrows née. Adding born is a behavior change with a real cost, measured:

'Max Born'            given='Max'     family='Born'    maiden=''      ← fine
'Bertha Born Smith'   given='Bertha'  family=''        maiden='Smith' ← family lost

A trailing Born is safe; Born in non-final position is not — which is exactly where a birth surname carried as a middle name sits. Left as a possible future release, not an issue.

Open the 2.1 cycle

DEFAULT_BASELINE still said 2.0.0 and no expected_since_2.1.0.toml existed, so a bare compare.py run measured against a minor further back than it reported. The two are coupled — _allowlist_for treats a missing ledger as a hard error — so the baseline could not advance without the file.

The new ledger is empty and correct that way: nameparser/ is byte-identical to the v2.1.0 tag, so this cycle has produced nothing to classify yet. That required one carve-out: test_validate_rules_accepts_the_shipped_ledgers asserted every ledger carries rules. It now permits exactly the ledger DEFAULT_BASELINE names — older ones are history, and history is not empty, so emptying one is still caught. The test also now asserts the open cycle's file exists at all.

Verified end to end, not by the summary line

Against 2.1.0 the harness reports 751 names; intentional diffs: 0; unexplained: 0. That is also what a harness comparing the installed wheel against itself would print, so it cannot stand alone. The discriminator:

baseline 2.0.0 -> 751 names; intentional diffs: 89; unexplained: 0
baseline 2.1.0 -> 751 names; intentional diffs:  0; unexplained: 0

The comparison is real, and the empty result is a measurement rather than a broken run.

_SPAN_BEARING_RULES records the new file with an empty set — the enrollment AGENTS.md step 8 prescribes, and this is its first exercise.

Scope

Tests, tooling and ledger comments. One behavior-adjacent line in compare.py (DEFAULT_BASELINE); no change to nameparser/.

🤖 Generated with Claude Code

derek73 added 3 commits August 8, 2026 13:10
The fix(#274) rule's alternation is a hand copy of MAIDEN_MARKERS that
nothing checked. It offered 'born', which is in no config constant and
never was, in v1 or v2 -- so that alternative could not correspond to a
real #274 change. What it could do is claim any name containing "born",
with fields maiden/middle/family, and absorb a genuine regression: the
rule matches 'Max Born'.

The pin is not the honorific pin's set equality, because these members
are regex fragments rather than entries -- n[ée]e covers two markers and
geb\.? covers one. It asserts the direction that matters instead: every
alternative must match at least one entry the vocabulary ships. The
covered set is recorded rather than equated, since widening to markers
no corpus name exercises buys nothing and 旧姓 already has its own rule
in the 2.0 ledger.

Removing 'born' changes no classification -- no name in any of the three
corpora contains it, which is also why it was never caught. English is
absent from the vocabulary deliberately (it borrows née); promoting
'born' would be a behavior change with a real cost, since 'Bertha Born
Smith' loses its family name to the marker.
A ledger is created the day its baseline is released, before that
cycle has produced a single behavior change, so it is empty and correct
until the first one lands. Two things refused that state: the
shipped-ledgers test asserted every file carries rules, and _rules read
["change"] where compare.py has always used .get("change", []).

The carve-out is exactly the ledger DEFAULT_BASELINE names. Older ones
are history, and history is not empty, so emptying one is still caught
-- and the test now also asserts the open cycle's file exists at all,
since _allowlist_for hard-errors on a bare run without it.
Release step 8 was outstanding from 2.1.0: the baseline still named
2.0.0 and no expected_since_2.1.0.toml existed, so a bare compare.py run
measured against a minor further back than it reported.

The new ledger is empty and correct that way. nameparser/ is
byte-identical to the v2.1.0 tag, so this cycle has produced no
behavior change to classify yet. It has to exist anyway, because
_allowlist_for treats a missing ledger as a hard error and the baseline
cannot advance without it.

Verified end to end rather than by the summary line alone. Against
2.1.0 the harness reports 751 names, 0 intentional, 0 unexplained --
which is also what a harness comparing the installed wheel against
itself would print, so that run cannot stand on its own. Against 2.0.0
it reports 89 intentional and 0 unexplained, which is the discriminator:
the comparison is real, and the empty result is a measurement rather
than a broken run.

_SPAN_BEARING_RULES records the file with an empty set, which is the
enrollment AGENTS.md step 8 prescribes and this is its first exercise.
@codecov

codecov Bot commented Aug 8, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.48%. Comparing base (1484c13) to head (ae7b3d9).

Additional details and impacted files
@@           Coverage Diff           @@
##           master     #351   +/-   ##
=======================================
  Coverage   98.48%   98.48%           
=======================================
  Files          41       41           
  Lines        2845     2845           
=======================================
  Hits         2802     2802           
  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 Aug 8, 2026
derek73 added 7 commits August 8, 2026 13:44
Review defeated the pin three ways, each measured through the harness's
own classify() against the real ledger.

It had no discovery direction. It iterated roster keys and skipped
every rule that matched none, so a hand copy under an unrostered tag
was pinned by nothing -- and one already was: the MA/DO acronym rule
copies SUFFIX_ACRONYMS_AMBIGUOUS and no test could see it. It now
iterates alternations, so an undeclared one fails; alternations that
copy no vocabulary are declared as such rather than skipped.

Appending "|[A-Za-z]" at depth 0 passed. The pinned alternation then
governs one branch of an unchecked whole, and a family-only regression
would be labelled fix(#274) on 675 of 783 corpus names instead of 4.
_top_level_alternation already existed for exactly this -- it was
applied one function away, inside the span-bearing sweep only.

A member of "[a-z]{3}" or ".{3}" passed, because fullmatch against the
vocabulary bounds what a member matches within 17 entries and says
nothing about what it matches in a name -- and every entry this rule
needs happens to be three characters. 320 of 783 names. Members are now
probed against ordinary name fragments too.

Also: a NamedTuple, since the roster's two set-shaped fields are not
the same kind of thing and the positional tuple said nothing about
which was the source of truth; re.error re-raised as an assertion
naming the ledger, rule and member, since a mis-split alternation can
manufacture an invalid fragment; and the vacuity guard its twin has.
Two defects in how the empty ledger was shipped, both found by trying
to use it.

`change = []` blocks the next step the file's own comment asks for:
TOML forbids appending a [[change]] table to a statically defined
array, so adding the first 2.2 rule raises TOMLDecodeError pointing at
the new rule rather than at the line that caused it. The line was never
needed -- both readers use .get("change", []), so an absent key already
means an empty ledger. Removed, and appending now works.

The carve-out that lets this one ledger be empty also lets it be
broken. A mistyped table header -- [[changes]], [[rules]] -- reads as a
legitimately empty open cycle everywhere: every sweep gets zero rules
and passes while the author believes they shipped a rule. The other
ledgers are protected by having to be non-empty, so this is the one
place it needs saying: nothing but `change` may be defined at the top
level of the open cycle's file.

The existence assertion moves out of test_validate_rules_accepts_the_
shipped_ledgers into a test named for what it checks. It is a fact
about the DEFAULT_BASELINE-to-ledger coupling in compare.py, not about
whether validate_rules accepts the shipped rules, and someone half way
through release step 8 will look for it by name.
Review measured every factual claim in the last round. These did not
survive.

"It covers the four the corpora exercise" was wrong on both halves. The
rule's members reach 4 of the 17 MAIDEN_MARKERS entries; the corpora
contain 3 markers; only 2 are in both sets. The next clause then
contradicted the first -- "widening it to markers no corpus name
contains would buy nothing" -- when nee and roz are exactly such
markers and were already in.

"旧姓 has its own rule in the 2.0 ledger, so claiming it here too would
make two rules answer for one diff" named the wrong file and a
mechanism that cannot happen: compare.py loads exactly one ledger per
run, so rules in different ledgers never compete. The rule that could
actually be shadowed is fix(cjk-maiden-marker), 20 lines below in THIS
file, which sorts after fix(#274) within the name_regex tier.

"English is absent from the vocabulary on purpose: it borrows née" was
invented. maiden_markers.py has a "Deliberately absent" note and it
names Polish z domu and Scandinavian f., not English -- and 'nee' is in
the vocabulary, which tests/v2/cases.py calls the spelling English
writes most often. The note now points at the real one.

_rules' comment described a key-absent shape the shipped ledger
contradicted; the ledger no longer defines `change` at all, so the
comment is now true, and it says where that leniency is policed.

Also sweeps a pre-existing leftover from the Role-vocabulary migration
in the same file: fields "maiden/middle/last" and "flips `first`" for a
rule that ships given/middle/family.

AGENTS.md release step 8 gains the third roster, and drops the claim
that the sweep needs no discovery. tools/differential/README.md's
quick-start hardcoded --baseline 2.0.0, which is now two minors back;
the bare invocation is the release-time run.
Four review rounds found the same defect in four costumes: a hardcoded
slug, then a hardcoded count, then a hardcoded probe list, then a
hardcoded nesting depth. Each guard closed the spelling that had just
been demonstrated and left the next one open, because every one of them
checked regex SYNTAX as a proxy for what a rule claims.

The last round made the pattern undeniable. "[acdf-uw-z]{3,}" walks
through all eight probe strings and claims 563 of 783 corpus names --
wider than the 320-name attack the probes were added to stop, and
derivable rather than lucky, since every entry the rule needs is three
characters so a member must accept one and is free everywhere else. And
a copy written as a character class rather than an alternation was not
undeclared but unseen: no roster demanded a key, nothing fired, 320
names claimed.

So stop asking how a rule is spelled and ask what it reaches. The
ledgers exist to explain diffs on the corpora and nothing else, so the
corpus is the whole population a rule will ever be asked about -- and
it answers without parsing anything, just a regex search.

Three guards, all corpus-derived:

- A span-bearing rule must claim no corpus name carrying an
  unclassified script. The depth-0 test could not see "(?:CJK|[A-Za-z])"
  hiding its pipe one level down; this does, because it never looks at
  the pipe. Both are kept -- the syntactic one gives a clearer message
  for the naive spelling and covers a widening toward a script the
  corpora do not happen to contain.
- Every corpus fragment a vocabulary member matches must BE an entry,
  normalized (the corpus writes "geb.", the config stores "geb") and
  exempting only that member's own vocabulary. Replaces the probe
  tuple, which could never be more than a spot check.
- Every rule claiming a role in _FIELD_VOCABULARIES must claim only
  names that carry that vocabulary. Keyed on `fields`, not on the
  regex, which is what closes the unseen-copy hole: a rule that does
  not claim `maiden` cannot be a maiden change, so no notation escapes
  it. It also reaches fix(cjk-maiden-marker), whose regex is the bare
  literal 旧姓 and which no roster in this module could see.

Verified by replaying every attack from all four rounds: depth-0
widening, wrapped widening, [a-z]{3}, [acdf-uw-z]{3,}, geb[^o]*, the
class-with-no-alternation rule, and 'born' itself. All fail; the
shipped rules stay green; removing an entry from MAIDEN_MARKERS still
fails.
Three rounds, three wrong versions of the same explanation, while the
correct one sat 30 lines below in the sibling rule's own note.

Round 2 said 旧姓 "has its own rule in the 2.0 ledger, so claiming it
here too would make two rules answer for one diff" -- wrong file, and a
mechanism that cannot happen, since compare.py loads one ledger per
run. Round 3 said the 旧姓 rule "sorts AFTER this one, so widening here
would shadow it" -- the sort order is right and the consequence is
wrong: classify() needs the diff's fields to be a subset of the rule's,
and both 旧姓 corpus names diff on `given`, which fix(#274) does not
claim. Widening it to 旧姓 is inert, not shadowing.

Which is exactly what fix(cjk-maiden-marker)'s own note has said all
along: #274's fields stop short of `given`, and that is why the
separate rule exists. Both copies now point there instead of inventing
a third mechanism.

Also: "the other 13" was 12 once 旧姓 is accounted for separately; the
roster comment said "this rule" three times while the roster now holds
two; the member-loop docstring counted three assertions where two are
per-member and one is per-rule; and "both readers" was four call sites
across three files.

AGENTS.md promised that "a copy the rosters cannot account for fails
loudly rather than going unpinned". That is true of the field-keyed
corpus check and false of the two syntax-keyed rosters -- a hand copy
that is not an alternation or a span class is invisible to them, as
fix(cjk-maiden-marker)'s bare 旧姓 literal was until this branch. The
step now says which mechanism covers what.
Round five defeated the round-four guards twice more, and the pattern
in the FIXES is now as clear as the one in the defects. Each guard was
scoped to a category -- a span class, an alternation's members, a
`maiden` field, a member's reach -- and each attack simply moved to a
rule outside whichever category the last fix covered.

feat(#273) is a nickname-delimiter character class: no span, so the
span guard skips it; no alternation, so the vocabulary roster never
discovers it; no `maiden` field, so the field guard skips it. Append
A-Za-z and it goes from claiming 6 corpus names to 668, with the whole
2232-test suite green. And deleting the acronym rule's delimiter
classes -- leaving its members untouched -- takes it from 0 to 193,
because the members really do reach those names on their own; the
narrowing lived in the context around them, which nothing measured.

The categories are the test's, not the ledger's. What every rule
shares is how much of the corpus it claims, and no widening can change
what a rule matches without changing that. So _CORPUS_CLAIMS records
the number per rule, per ledger, and refuses to let it move quietly.

It is deliberately dumb. It knows nothing about vocabularies, scripts
or roles and cannot say whether a number is right -- only that it
moved. The specific guards stay, because they explain WHY a rule may
claim what it claims and their messages are the ones worth reading;
this is the backstop none of them could be. Keyed on the full `issue`,
since the 1.4 ledger has two rules tagged feat(#269) and a tag-keyed
roster cannot tell them apart.

Verified by replaying every attack from all five rounds: both of round
five's, the nested-and-widened acronym, [acdf-uw-z]{3,}, depth-0
|[A-Za-z], the class-with-no-alternation maiden rule, and 'born'. All
fail. The counts move when the CORPORA move too, which is the intended
cost -- a corpus name landing under an existing rule changes what that
rule explains, and should be read once rather than absorbed.

Also replaces the module's own "> 500" corpus floor with compare.py's
per-file _CORPUS_FLOORS. A total cannot see a file vanish: emptying
corpus_issues.jsonl left 583 names and every test green, silently
dropping a quarter of the population every guard here measures.
Round six defeated the count three ways, and the root of all three is
that a count of regex matches is not what a rule claims.

classify() narrows on `name_regex` AND `fields`. _CORPUS_CLAIMS
recorded only the first, so widening a rule's roles moved nothing it
could see: the comma rule kept its `,` regex and its 236 names while
going from explaining 6 of the corpus to 242. The fields-only rule --
no name_regex at all, the most unbounded shape validate_rules permits
-- was not recorded at all, and widening it to six roles absorbed
751/751 on every diff shape not touching `title`.

A count is also identity-free, the weakness this module rejects at
_SPAN_BEARING_RULES. Swapping feat(#273)'s delimiter class for a single
accented letter held the count at 6 while claiming six entirely
different names.

And it was keyed on `issue` in a dict comprehension, so a duplicate
silently kept the last rule written. Nothing asserts issue uniqueness
-- validate_rules wants only a non-empty string -- and the collision is
worse than it sounds: _sorted_rules is stable, so FIRST in file is the
position classify reaches, and first in file is exactly what the
comprehension discards. Coverage by file order is no coverage.

So record a _Claim: the regex's corpus reach, the sorted roles, and a
digest of which names. Plus the uniqueness assert, which the two
substring-keyed rosters lean on as well.

Also corrects the framing, which had the layers backwards. This is a
change DETECTOR, not an enforcer -- inert for a new rule, and its own
message invites the re-record that defeats it. The member and
vocabulary guards are the walls, because they judge a rule wrong at any
time including at recording time. This catches what none of them is
scoped to see and holds it still long enough to be read.

Deletes the duplicate corpus-floor test, which restated two tests
test_differential.py already owns and re-copied their compare.py loader
to do it. What is local to this module is the deduplicated population
its guards measure, so that is what stays.
@derek73
derek73 merged commit f1bb26f into master Aug 9, 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.

The #274 maiden rule matches "born", which is not a maiden marker, so a regression on "Max Born" reads as intended

1 participant