Skip to content

feat(#397), fix(#461): the Catalan link joins, and one initials rule for every group - #536

Open
derek73 wants to merge 11 commits into
masterfrom
claude/397-461-conjunction-i
Open

derek73 wants to merge 11 commits into
masterfrom
claude/397-461-conjunction-i

Conversation

@derek73

@derek73 derek73 commented Sep 20, 2026

Copy link
Copy Markdown
Owner

Closes #397. Closes #461.

#397 — the Catalan and Polish surname link joins

i joins the default conjunctions, marked in conjunctions_ambiguous beside e.

Input Before (every release) After
Josep Carod i Rovira, Antoni Gaudí i Cornet, Jan Kowalski i Nowak middle Carod i, family Rovira family Carod i Rovira
Josep Lluis Carod i Rovira middle Lluis Carod i middle Lluis, family Carod i Rovira
Carod i Rovira, Josep family Carod Rovira, suffix i family Carod i Rovira
JOSEP CAROD I ROVIRA, josep carod i rovira initial, silent initial, reports conjunction-or-initial
Josep i Rovira (three words), John I Smith, Josep Carod I. Rovira, John Smith I, John Quincy Smith i, Josep Lluis Carod i III, Henry I, Maier, Amy I, Jr. unchanged

Two rule changes make the word work (adding it alone fixed only names of five or more words):

  • A single-letter connective counts itself toward P3's three-word carve-out whatever else the vocabulary says it is (i is also the roman numeral, so it was excluded from the count it needed) — but only where it is placed to join.
  • A connective that is also generational vocabulary joins only where a name word stands on each side — not a generation, a credential or a title. Otherwise it is the generation it also spells and changes nothing.

The off switch, and the invariant the tests enforce. A caller who wants none of this removes i from conjunctions, which restores every prior field and every prior report, with two readings it does not restore: a letter the two vocabularies disagree about being an initial reads as one here and as the generation there, and case repair leaves a connective the parse placed among the NAME words in lower case where the off switch title-cases it (Dr. John i Smith forced keeps i). Property tests hold the parser to exactly that: an i that does not join changes no field and no repaired case. Catalan and Polish callers who want one-case records joined remove the marking instead, remove(conjunctions_ambiguous={"i"}) (recipe in customize.rst, tested on ALL-CAPS names).

One-case names, stated plainly. i being marked means a name written wholly in one case reads it as an initial and reports, exactly as e does since #383. So JOHN I SMITH now reports, and in ALL-LOWER names the initial reading moves fields where master read a lower-case i as the generation — each now reads as its all-caps twin already did: rovira, i → given i; maier, amy i, jr. → middle i (as Maier, Amy I, Jr.); john smith i jr. None is in the corpus. Recorded under P3 in decisions.md.

Accepted: Carod i (two words) keeps the generation reading (2.0–2.3 parity; 1.4.0 read family i); Henry i of England reads as a connective run, the Duke of Edinburgh shape; under a custom lexicon listing y as a particle, Juan Velasquez y Garcia now joins (the count reaches the class, by decision).

#461 — one initials rule for every group

A connective that joins never initials — in the given group too — and a part holding nothing but the connective initials like a name word, agreeing with family_base. The whole part decides, never a word count.

Input 1.4.0 Before After
John and Jane Smith J a J. S. J. a. J. S. J. J. S.
Duke of Edinburgh / John & Jane D o E. / J & J. D. o. E. / J. &. J. D. E. / J. J.
Juan y / Juan de y J. J. (but family_base is y) J. y.
Juan y Garcia J. y. G. J. G. J. y. G. (restores 1.4.0)
Juan Velasquez y Garcia / Jon Dough and J. V. G. / J. D. unchanged

parse().initials() and HumanName.initials() move together (46 corpus names at the pre-branch corpus: 18 gain a letter, 28 lose one; identical on both surfaces). The given-group half diverges from 1.4.0; the lone-part half mostly restores it and retires two ledger rules. capitalized() does not change for any name whose roles did not move; rules.md#R4 now states its conjunction reading as its own rule instead of borrowing R3's. One mark, written by one walk that also writes R2's all-particle mark, which is why the reference frame counts FALL: 412/449 → 406/443, inside the bands, no baseline edit.

What review found (both fixed on the branch)

  • The first both-sides gate tested position, not class: Josep Lluis Carod i III read family Carod i III, suffix empty. The invariant written for it could not see it, judging the part after the join had absorbed the word. Fixed with a neighbour-class test, the count agreeing with the join, and the off-switch invariant (2,360 failures on the pre-fix commit, 0 now; strengthened INV1 960 → 0).

  • Forced case repair lower-cased a generation: capitalize(force=True) gave John Smith i where every release gave John Smith I, R4's conjunction conjunct being applied to a suffix-roled token. Fixed; a second off-switch invariant over both repair calls and both surfaces (11,341 failures before, 0 now).

  • A link inside a maiden clause (found while drafting the follow-up the spec had planned, and fixed here by Derek's decision): the maiden walk ends a birth name at any suffix word, and a lower-case i is the roman numeral, so Jane Doe nee Puig i Soler was cut to maiden Puig on master — and on this branch the leaked i Soler then JOINED the current surname (family Doe i Soler). The walk now keeps a link that has a name word of the clause on each side, by the same class test the join uses: maiden Puig i Soler, as the y spelling always read. Jane Doe nee Puig i and … i III are unchanged. Two invariants pin it: the y twin (10,540 failures before, 0 now) and no birth-name word in the current name (28,985 tokens before, 0 now).

  • CI time: the invariant tests first took the build from ~5 to 17–25 minutes; they now share one parsed grid (seven tests 103.7 s → 14.8 s; builds back to 3–6 minutes) with every killing mutant still killing.

  • The toolkit review round (four reviewers) found three more, all fixed: forced repair capitalized a PLAIN connective in the suffix field (Smith, John, andAnd; the role test now also requires generational vocabulary); a credential behind a trailing title was absorbed by the join (John Quincy Adams i MA Prof. → family Adams i MA, report lost; the bound now comes from the reading that sees past trailing titles); and JOHN QUINCY SMITH I carried a conjunction-or-initial report saying "read as an initial" on a token roled SUFFIX (withdrawn where the role is settled; those names report exactly as master again). Each has its own invariant, proven to fail on the pre-fix commit. A run of generational connectives was quadratic ("Josep " + "i "*800 + "Rovira" 58.6 ms vs master's 4.1); the neighbour question is now answered once per run (5.1 ms) and two benchmark guards hold it.

Commits

  1. feat(#397) the join · 2. fix(#461) the initials rule · 3. fix(#397) review: name words on both sides · 4. ledgers at five baselines · 5. docs · 6. fix(#397) a generation repairs as a generation + docs review · 7. the invariant tests share one parsed grid · 8. fix(#397) a link inside a maiden clause stays in the birth name · 9. review round · 10. perf(#397) a run of connectives is asked about once.

Verification

  • Suite 9646 passed, 324 skipped, 4 xfailed; mypy, ruff clean; sphinx doctest 255 / 0; coverage 100% on _group.py, _render.py, _post_rules.py, _types.py.
  • Frames parse 406.00 / facade 443.00 (from 412/449); names with no generational connective pay nothing; join loop, walk and maiden clause linear to 1,600 words.
  • Thirteen invariant property tests over shared cached grids, each proven able to fail; mutation tables on every new conditional.
  • Differential gates, all 0 unexplained: 1.4.0 1332 names / 542 intentional; 2.0.0 1339 / 506; 2.1.0 1339 / 418; 2.2.0 1339 / 272; 2.3.0 1339 / 161. Attribution by measurement against the parent moved eleven 1.4.0-parity breaks off an older, broader rule that had been absorbing them; the 1.4.0 rule fix(#397) NOT WANTED is rewritten as the accepted two-word boundary rather than deleted.
  • Blast radius at the pre-branch corpus × 8 configurations: 2 role movers, 3 report-only, 46 initials.
  • Independent code review of the behavior commits and a design-docs review, both folded in.

🤖 Generated with Claude Code

derek73 and others added 6 commits September 20, 2026 03:44
Catalan and Polish link two surnames with `i` the way Spanish links
them with `y`, and `i` was not connective vocabulary at all, so the
link was never joined. `parse("Josep Carod i Rovira")` gave middle
'Carod i' with family 'Rovira' -- the family one word short of what a
Catalan reader wants -- and after a family comma the link was not
merely unjoined but read as a GENERATION, so `Carod i Rovira, Josep`
gave family 'Carod Rovira' with suffix 'i' and the surname lost the
link outright. Every release from 1.4.0 read all three that way.

Adding the word alone does not fix the commonest shape, and the issue
said why. `i` is a bare entry in the roman-numeral suffix vocabulary,
`_is_rootname` refuses a suffix piece, and the carve-out that keeps a
single letter a name word in a short name counts rootnames -- so the
link shrank the very total it had to clear and needed one more name
word than `y` does before it would join.

So a connective counts as a name word wherever that rule counts them,
whatever else the vocabulary says the word is. A word can be a
connective and a generation at once, and counting it as the generation
lets a connective raise the bar for its own join, which would leave
the shortest linked name -- the commonest one -- as the only one that
failed to link. The test is inlined rather than a call: it runs once
per piece of every parse, and inlining it makes every connective name
three frames cheaper.

That alone would take a trailing link too, so a connective that is
also generational vocabulary joins only where a name word stands on
each side of it. `John Quincy Smith i` keeps suffix 'i': a connective
with nothing to its right is connecting nothing, and a word of that
vocabulary ending a name is the generation it also spells. The rule is
about the CLASS and not the letter -- measured against a caller's own
`add(conjunctions={"v"})`, which behaves identically, and against
`add(conjunctions={"x"})`, a roman letter outside the suffix
vocabulary, which is untouched. It is narrow by construction: it
cannot see a trailing `y` or `and`, which is what leaves those
readings where they were.

Reaching the class has one accepted cost, pinned rather than hidden:
a connective a caller ALSO makes particle vocabulary was refused the
same way, so under `add(particles={"y"})` the reading of `Juan
Velasquez y Garcia` moves -- onto the answer the default vocabulary
already gave. `Henry i of England` moves with it, its two adjacent
connectives merging into a run that now counts itself; that is the
shape `Duke of Edinburgh` already had, and it is recorded rather than
repaired.

`i` ships in the marked subset beside `e`, a bare I initial being as
common as a bare E, so a name written wholly in one case reads the
letter as an initial and reports the fork rather than joining in
silence -- `JOSEP CAROD I ROVIRA` and `josep carod i rovira` keep
today's reading and gain a `conjunction-or-initial`. A caller with
Catalan or Polish data removes the entry from the subset and gets the
join back.

Two names keep the old reading deliberately. `Carod i` never reaches
the join loop, which needs three pieces, so it stays a name plus a
trailing generation; and `Josep Carod i` is refused one gate earlier,
by the three-word carve-out itself.

Over every corpus name under eight configurations this moves two roles
and three reports, and the reference frame count is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
R3 said a CONJUNCTION never initials, so a base that is one
contributes nothing even then -- and the parser did not do that:
`parse("Juan de y").family_base` was 'y' while `.initials()` was 'J.',
so the two views of one parse disagreed about one token. The rule's
scope was never settled for the given group at all: R3 said so in
prose and listed four candidate answers for `John and Jane Smith`,
which had initialed its connective since 1.4.0 with nothing but the
differential corpora holding it.

The clause is now conditional, and it is one sentence for all three
groups. A connective contributes nothing where it is JOINING: a part
holding another name word for it to join -- the part's working
particles set aside -- is a part where the connective is doing a
connective's work and is no name word of its own. A part holding
nothing else is a part where it is joining nothing, and there it
initials like any other name word, agreeing with the base.

The question is asked of the WHOLE PART and never of a word count.
`Jon Dough and` has base 'Dough and' and keeps initials 'J. D.',
because the other word is a name word; a part of three is no
different. That is what the given group's old blanket exemption was
standing in for, and dropping the exemption is the whole of "one rule
for every group".

The criterion is a fact about a PART, so it is decided ONCE where the
parts are settled and recorded on the token, beside the mark R2
already writes there -- the direct answer to what the first attempt at
this found, since a view that re-derives a part-level fact is how
these two came apart in the first place. Both views read the mark:
the core's filter, and the facade's conjunction predicate, which needs
no signature change to see it.

The two marks now come off ONE walk instead of two generator
expressions, which makes every parse six frames cheaper: 412 -> 406
for `parse` and 449 -> 443 for `HumanName` on py3.11, inside the
recorded band, so no baseline moves.

Over every corpus name the change moves 46 initials on both surfaces,
the same 46 names with the same values -- 28 losing a letter where the
connective is joining, 18 gaining one where it is not. Two of the
gains are 1.4.0 parity RESTORED: `محمد و علي` and `JUAN Y GARCIA` now
give the answer 1.4.0 gave. Five invariants over 170,100 generated
parses hold at 0 failures, and each was mutation-checked against the
branch it is stated about.

Five no-parse paths keep the pre-#461 reading -- a spliced field, a v1
pickle or copy, the keyword constructor, an overridden public list
property, and a widen-only override -- through the same vocabulary
fallback and for the same reason #528's own accepted clause records.

`capitalized()` does not follow, and that is R4's own rule rather than
an oversight: a connective that initials because it joins nothing is
still not written the way a name is written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ion that joins nothing does not count itself

The both-sides condition #397 shipped tested POSITION where it meant
CLASS. `not (0 < k < len(pieces) - 1)` asks whether some piece stands
on each side of the link, and some piece always does, so a
generational suffix standing behind the link was swallowed into the
surname in silence: `Josep Lluis Carod i III` read family 'Carod i
III' with no suffix at all, where the parent and 1.4.0 both read the
generation. `... i Jr.`, `... i I`, `... i V`, `... i MA`, the comma
form `Rovira, Josep Carod i Jr.` and the doubled `... i i` went the
same way -- 956 names of the review's 93,565-name grid, pinned by no
test and by no corpus row. The case note that called the contrast
"the whole design" was false of every one of them.

The join now asks the NEIGHBOURS' class: a name word is what the REST
of the parse reads as one. Two bounds do most of it, and both come
from assign's own predicates rather than from a second reading of the
vocabulary (mechanisms.md#ONE-PREDICATE-PER-QUESTION) -- `leading_
titles` for the honorifics in front, `trailing_start` for the
credential run behind. `trailing_start` is what makes the bare 'V'
and 'MA' cases work at all: 'V' is suffix vocabulary written like an
initial, so is_suffix_piece refuses it under S2's initial veto and a
class test spelled with that predicate alone would let the link take
it. Inside the bounds the suffix and title tests still run, for the
credential or honorific standing mid-name that neither bound reaches.
The walk steps OVER a run of connectives, because a run joins as one
-- `Carod i y Rovira` joins on 'Rovira', while `Juan i e` has no name
word on that side at all and keeps the parent's reading.

It is read once per SEGMENT, lazily, and kept as a TOKEN index: the
merges below move piece indices and cannot move that one, and a name
with no one-letter connective of the suffix vocabulary pays two tag
lookups per piece and no call. The reference frame count is
unchanged at 406/443, as are `John Smith`, `Smith, John`, `Juan
Garcia y Lopez` and `John and Jane Smith`; the `i` names pay between
17 and 49 frames. An alternating-connective part stays linear from 50
to 800 words (x1.99 per doubling, as before).

The frozen set is tested by a piece's FIRST TOKEN rather than with
`isdisjoint` over its tokens, and the benchmark's own "and " shape is
why: the piece a connective run extends GROWS with every merge, so a
test over its tokens costs 1+2+...+n and took 'and ' x3200 from
21.7ms to 41.8ms -- 6.2x per 4x the input against the shape's
recorded 4.1x, over `_MAX_RATIO`. The first-token test is exact and
not an approximation (a frozen piece is one token and nothing merges
it), and the two spellings are output-identical over the review's
93,565-name grid and over every corpus and case text under eight
configurations.

The COUNT now agrees with the join, which is the review's first
question. A link that joins nothing is the generation it also spells,
so it no longer counts itself toward P3's three-word carve-out --
`Carod y de Rovira i` returns to given 'Carod', middle 'y', family
'de Rovira', suffix 'i', where the trailing generation had been
lending its own word to an unrelated 'y' two pieces away. The same
frozen set keeps such a link out of the contiguous-connective run
merge. Derek's Q2 decision stands: the count still reaches the CLASS,
and `test_the_count_reaches_a_connective_that_is_particle_vocabulary`
is green.

Two new property invariants, both over a second grid carrying the
shapes the first cut got wrong (the trailing numeral and bare acronym
the peel takes, a maiden clause, initial-only and particle-only
heads, and the suffix comma as a third comma shape; 103,040 entries,
12,880 texts, two lexicons and four policies).

INV6, the OFF-SWITCH invariant: for every name, either a link JOINED
between two name words -- judged on the parse with the letter out of
the connectives, so an absorbed suffix cannot pass itself off as the
name word on the right -- or the seven fields are identical to that
parse. It fails on 2,360 parses at c8550b6 and on none here. INV1
strengthened: a word the off-switch parse reads as the SUFFIX never
lands inside a joined name part. It fails on 960 parses at c8550b6,
where INV1 itself -- which inspects the part a join PRODUCED, and so
is satisfied by the very defect -- fails on none.

Collateral, measured: over the corpus as it stood at the parent, under
eight configurations, the bundle moves 2 roles / 3 reports / 46
initials, and this commit moves none of those numbers -- they are
identical at c8550b6 and here, per configuration. On the review's
93,565-name grid every one of the 956 names this commit moves lands
back on the parent's exact reading, none on a third; and of the 2,369
that still differ from the parent, 761 are a link joining between two
name words and the other 1,608 are one-case names where the letter
reads as an INITIAL, which is P3's marked-subset clause and not this
one.

`_process_initial`'s closing comment named a case #461 had emptied
(`Vega, Santa de y` now initials 'S. y. V.'). Rewritten to what is
true: over 95,119 names no PARSED group reaches that drop, and what
keeps the branch alive is the paths with no parse to read -- the
keyword constructor and an overridden `*_list` -- both now pinned.
`_remarked`'s `lone_conj` takes the same `bool(part)` guard `alone`
already had; nothing below the loop can see the difference.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five ledgers, and the gate exits 0 at all of them with `unexplained: 0`
and no radar diff left unclassified beyond the seven the bundle never
touched.

The Catalan join gets a ROLE rule and, from 2.0.0, a report rule of its
own: `_ambiguities` cannot diff below 2.0, so the three one-case
spellings that gain a connective-or-initial are unclassifiable at 1.4.0
and need no rule there. Their ROLES do not move, and that is the marked
subset working rather than an accident of these spellings -- the
lower-case forms where the letter used to be the generation do move
fields, and no corpus carries one. The five names the join moves are
new to the CONTRACT tier as well as to these files: they entered the
corpus as rules.md example lines and case rows, having been radar-tier
names the contract did not answer for.

The initials movers split by CAUSE rather than by baseline: a
connective holding its part alone gains a letter, a connective with a
name word beside it loses one, and the two get separate rules because
they are separate readings of one sentence and a rule listing
`_initials` may list nothing else.

WHICH HALF OF A DIFF BELONGS TO THIS CHANGE was measured name by name
against the parent as well as against each wheel, and it decided where
several names sit. At 1.4.0 the joining half was already diffing on the
2.0.0 per-word grouping, so its nineteen names stay on that rule and
the rule gains a dated paragraph naming both halves -- its claim that
every name it explains agrees with 1.4.0 letter for letter stopped
being true. The readmission half had no 1.4.0 diff at all: fifteen
names agreed with the wheel before this bundle and diverge from it now,
which is a parity break stated in its own rule rather than absorbed by
an older one. Eleven of the fifteen are reached by the grouping rule's
alternation too, so each is pinned in _RECORDED_DIFFS and
_CROSS_RULE_WINNERS with the argument for the winner beside it.

Three names are new to the corpus and move NOTHING: 'Josep Lluis Carod
i III', 'Josep Lluis Carod i V' and 'Rovira, Josep Carod i Jr.' parse
byte-identically at the parent and here. Their diffs are older
divergences seeing the gate for the first time, and they go under the
rules that caused them -- the space-separated post-nominal run at four
baselines, and a trailing two-word generation run peeling whole at
1.4.0.

TWO rules retire, and both are this bundle restoring 1.4.0 parity. The
1.4.0 entry recording that a recognized non-Latin connective
contributes no initial explained one name, and that name now gives
1.4.0's own answer on both surfaces. The 2.3.0 entry recording that a
bare capital connective in an all-upper name stops initialing explained
one name too, and that name stops diffing. Both are deleted rather than
declared dormant: their reasons are wrong now, not merely unreached.
The second of the two also leaves the 1.4.0 facade rule's alternation,
which narrows from four names to three.

The 1.4.0 rule for the two-word link is REWRITTEN IN PLACE rather than
deleted, which is the opposite of what its own comment predicted. It
said to delete it when #397 landed; #397 landed, and the name it
explains is unchanged -- a connective with nothing to its right is
connecting nothing, which is the boundary the new rule states. Its
standing moves from NOT WANTED and unresolved to the decided boundary
of a landed rule, and its _CROSS_RULE_WINNERS pin and _MUST_NOT_MATCH
probes follow the new title.

And one name keeps its rule while FLIPPING SURFACE. At 2.0.0, 2.1.0 and
2.2.0 the wheels give facade 'J. G.' and core 'J. Y. G.' for 'JUAN Y
GARCIA'. Until now only the core moved; now only the facade does, and
the core agrees with the baseline word for word. One pseudo-field
covers both, so the rule stands and its dated paragraph -- which said
the opposite -- is amended in all three files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
decisions.md gains five dated bullets and edits none.

Under P3, the count decision and the question it closes: a word's
membership in another vocabulary does not discount it from a count
gating a rule keyed on connectives, which is the question #418's entry
left open. The both-sides scope is recorded with its measured
alternatives, and the measurement corrects what the design predicted:
the absent scope is separated from the shipped one only by rows this
bundle added, while the WIDE scope -- the same condition without the
generational-vocabulary test -- moves three names that were in the
corpus already, 'Mr. & Mrs. John Smith' among them. The review that
replaced a position test with a class test is recorded with the
credential it stopped swallowing. The one-case half is stated
honestly: the letter is in the marked subset, so a one-case name
reports wherever a bare i stands among the name's own words, and in
an all-lower name that reading moves fields, each such name reading
as its all-caps twin already did. The Excluded block now lists one
letter against two marked ones, recorded as a decision rather than
left implicit, and P3's Open block is closed with it.

Under R3, the clause as taken and the three candidate answers it rules
out, both views reading one mark, the blast radius recomputed over the
population as it stands and over the parent's, the two names it
restores to 1.4.0 parity, the six frames it saves, and the no-parse
paths that keep the older reading.

Under R2, an AMENDMENT to the 2026-08-29 bullet that said none of four
names could move: two of them moved and two did not, and the bullet
says which half survived and why -- the criterion is the PART and
never a word count, which is exactly what keeps the other two fixed.
The backout that bullet records was right about the mechanism even
though the clause it preserved is the thing that changed.

Under R4, the behavior of the clause did not move and the grounding
did: the cross-reference to R3's carve-out is cut and replaced by this
rule's own reason. A sweep run to check that claim found a second one
the design had missed -- capitalized(force=True) leaves a lower-case
i alone on ten names where nothing else moved, where every release
title-cased it, so the vocabulary addition carries an unrecorded
1.4.0 parity break on the forced call alone.

A cross-reference under 3-0-reevaluations notes the marked subset now
holds two letters, so the parked per-locale entry describes a set of
seven single-letter conjunctions rather than six.

mechanisms.md records the new marker beside its twin, CLOSES the open
paragraph about a view overriding the record -- correcting the two
default-vocabulary values it quoted, both of which moved -- and gains
a field note: an invariant judged on the part a join PRODUCED is
satisfied by whatever the join absorbed, so judge on the pre-join
classes or against a parse with the rule switched off.

customize.rst gains the Catalan/Polish removal recipe beside the
Portuguese and Dutch ones, and the release log gains two Behavior
Changes bullets -- plus three IN-PLACE corrections, since the
unreleased 2.4.0 notes claim a name's initials move this cycle when
after this change they do not move at all, and say the marked subset
holds one letter.

One fix that predates this bundle and was found sweeping P3: the rule
said the v1 facade's initials() still reads a one-letter connective by
vocabulary and shape, so `HumanName("john e smith").initials()` stays
"j. s." for now. #528 landed and it is "j. e. s.".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A word can be the Catalan link and the roman numeral at once, and
where the parse read the GENERATION the token still carries the
`conjunction` tag classify gave it. Case repair's conjunction conjunct
read that tag without asking the role, so a suffix was lower-cased as
a connective: parse("John Quincy Smith i").capitalized(force=True)
gave 'John Quincy Smith i' where every release through 2.3 gave
'John Quincy Smith I', and so did 'Carod i', 'Josep Carod i',
'Lluis Carod i', 'Josep Lluis Carod i III', 'Josep Lluis Carod i V',
'Carod y de Rovira i' and 'Rovira, Josep Carod i Jr.'. Both surfaces,
the facade repairing through the same helper.

_cap_word now asks the ROLE the parse decided and not the tag alone
-- one comparison on a value already in hand, ahead of both
conjunction arms. The role is what the parse decided; the vocabulary
is only what the word could have been. Frame-neutral: py3.11
parse=406.00 facade=443.00, unchanged, and the benchmark band is
untouched.

What is NOT a defect and stays: a connective the parse placed among
the NAME words keeps its lowercase, so 'Carod i Rovira' and
'Josep i Rovira' repair to themselves -- exactly the answer the same
shape has always had for 'y' ('Carod y Rovira', 'Josep y Rovira').
Those two are the whole of what this cycle moves on that view, down
from ten.

tests/v2/test_properties.py gains INV7: a letter that became no
connective of this name repairs as the off-switch parser repairs it,
plain and forced, on both surfaces, exempting only the names whose
fields moved, the initial-reading disagreement, and R4's own
name-part connective. Recorded control: 11,341 repairs fail it at
e540d4c (10,424 core, 917 v1) and 0 here; removing the role test
alone fails it on the same 11,341, and fails the named unit test in
tests/v2/test_render.py while leaving the two existing R4 pins green.

rules.md: R4's sentence gains its second clause and two
capitalized_forced example lines, both names already in the corpora
so no corpus or claim moves. P3's both-sides clause said the
neighbour must not be a credential or an honorific and omitted the
GENERATION, which licensed the reading its own example forbids;
the _group.py citation moves with it. P3 and S2 now name each other
under interacts:, the new clause having moved a pre-comma generation.

decisions.md, correcting three claims in bullets this branch added:
'Carod i' is 2.0.0-2.3.0 parity and not 1.4.0's, which read family
'i'; 'JUAN Y GARCIA' gave 'J. G.' at 2.0.0-2.2.0 and 1.4.0's own
answer at 2.3.0, while the Arabic name gave the 2.x answer across the
whole range; and R4's forced-call paragraph is rewritten from
"recorded rather than repaired" to the repair, with the invariant's
numbers. mechanisms.md narrows RENDER-HONORS-THE-PARSE's remaining
disagreement a second time and says what the defect taught: a view
honors what the parse DECIDED, and a role is that decision where a
tag is only what the word could have been.

release_log.rst no longer ships the defect as a behavior change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@derek73 derek73 added this to the 2.4 milestone Sep 20, 2026
@derek73 derek73 added bug enhancement docs Documentation fixes and updates labels Sep 20, 2026
@derek73 derek73 self-assigned this Sep 20, 2026
@codecov

codecov Bot commented Sep 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.78%. Comparing base (4665175) to head (59d1443).

Additional details and impacted files
@@            Coverage Diff             @@
##           master     #536      +/-   ##
==========================================
+ Coverage   98.76%   98.78%   +0.02%     
==========================================
  Files          45       45              
  Lines        3554     3633      +79     
==========================================
+ Hits         3510     3589      +79     
  Misses         44       44              

☔ 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 5 commits September 20, 2026 09:09
The seven invariants this PR added took 104s of the property module's
115s locally, and carried CI's build jobs from about 5 minutes to
17-25 (3.11 17m26s, 3.12 25m29s, 3.13 16m32s) -- every parse costs
several times more under the coverage tracing CI runs, and every
later PR pays it. Two causes, both multiplicative, neither of them a
shape the tests needed.

FOUR TESTS, ONE PARSE. The four connective invariants asked four
questions of the same 170,100-row grid and parsed it four times over;
the three off-switch invariants parsed every row of theirs twice
apiece, six parses of a row. Each grid is now walked ONCE --
`_connective_findings`, `_off_switch_findings` -- and each test reads
its own failure list out of that walk: same rows, same order, same
message, every predicate left where it was and spelled in the terms
its own rule is stated in. The walk decides nothing; it only asks
them all while the parse is in hand.

ROWS THAT COULD NOT DIFFER. Both grids were a full cross product of
texts x lexicons x policies, and most of it was one configuration
re-deriving another's answer: a lexicon that ADDS a word changes no
parse of a name that does not hold that word, and
lenient_comma_suffixes decides nothing in a name with no comma. Every
variant now declares the words that reach it and takes only the texts
holding them (`_rows`). The TEXTS are untouched -- 14,175 and 12,880,
the two pinned counts that did NOT move, which is the number that
would say a shape was dropped -- and the rows fall 170,100 -> 55,800
and 103,040 -> 53,312. A shape trim and not a sample, measured before
it was taken: over the full cross product of both grids, 177,980
collapsed rows, 0 of them differing from the row they collapse onto
in fields, token tags and spans, initials() on every group, the four
derived views, capitalized() plain and forced, or the reports. And
self-maintaining, which a hand-picked subset would not be: no text of
the connective grid holds a standalone 'v', so conj+v earns no row
there until a generator grows one, and then earns them again by
itself.

Measured locally, py3.11, this machine: the seven tests 103.7s ->
14.8s (7.0x); tests/v2/test_properties.py 114.9s -> 25.6s; the whole
suite 134.6s -> 44.1s; the suite under --cov=nameparser, which is
what CI pays, 383.1s -> 122.2s (3.1x), with the coverage table
byte-identical -- 100% still on _group.py, _render.py, _post_rules.py
and _types.py. Counts unchanged at 9557 passed / 324 skipped / 4
xfailed.

FAULT DETECTION IS PRESERVED, and a trim that is not proven to be is
worth nothing. Twelve mutants of the code this arc got wrong, plus
the two historical commits the invariants were written against, run
against a scratch copy of the package; every one that killed a test
before still kills the same test, count before -> after:

  M-a  freeze gate tests POSITION (the c8550b6 bug)
         INV6 1120 -> 610, INV1-strengthened 960 -> 534
  M-b  count drops the frozen exclusion      INV6 640 -> 342
  M-c  both-sides condition dropped whole
         INV1 504 -> 162, INV6 3920 -> 2070, INV1-str 960 -> 534
  M-d  _cap_word drops `role is not SUFFIX` (the e540d4c bug)
         INV7 11,341 -> 7,382
  M-e  lone-part mark never written          INV2 14,078 -> 4,639
  M-f  mark walk's branches swapped          INV3/4 188 -> 94
  M-g  given-group exemption restored        INV5 27,103 -> 9,308
  M-h  _UNJOINED_MARKS narrowed to one       INV5 14,078 -> 4,639
  M-i  facade predicate ignores the mark     survives both (v1
         initials are no view of these seven; test_facade.py owns it)
  M-j  _name_word_beside ignores is_title_piece   survives both
  M-k  ... ignores the `hi` bound
         INV6 640 -> 382, INV1-strengthened 640 -> 382
  M-l  ... ignores the `lo` bound            survives both
  c8550b6  INV6 2,360 -> 1,250, INV1-str 960 -> 534,
            INV7 8,518 -> 5,691
  e540d4c  INV7 11,341 -> 7,382, of which the v1 arm's 917 both
            before and after -- those rows were never duplicated

No mutant had to have shapes put back. The counts in the invariants'
docstrings are counts over the grid, so every one of them is
re-measured here and re-recorded there beside the figure the full
cross product gave; INV1's two stale ones, describing a gate the
review replaced at 41de3df, are restated as what today's tree does.

AGENTS.md carries both rules now, next to the guard-test convention:
an invariant over an existing grid joins that grid's walk, a new grid
states its runtime, and a grid is trimmed by shape with the duplicate
rows proven duplicate first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The maiden walk ends the birth name at the first suffix WORD after
the marker, and the Catalan link is also the roman numeral, so
`Jane Doe nee Puig i Soler` read maiden 'Puig' and left 'i Soler'
standing. That truncation predates this PR: master 4665175 read
middle 'Doe i', family 'Soler'. What this PR added is the harm --
with the link joining, the leaked words merged into the CURRENT
surname, and 0fbcaa0 read family 'Doe i Soler'. The comma form
leaked into the given part (`Doe, Jane nee Puig i Soler` -> given
'Jane i Soler') and a Catalan head lost the distinction between its
own link and the clause's (`Maria Carod i Rovira nee Puig i Soler`
-> family 'Carod i Rovira i Soler'). The spec's Q3 called this
interaction unchanged; it was written from the rule, not from a
parse. Derek decided 2026-09-20 to fix it here.

The walk now steps over a suffix piece that is a connective PLACED
TO JOIN inside the clause, through the join's own predicates
(`_is_conj_piece`, `_name_word_beside`) rather than a restatement of
them. Two bounds make it the clause's question: the left is the
first piece after the whole marker run, so the marker is no name
word; the right is where assign's trailing run begins over the
pieces as written, read off the peel pair already in hand. After:
maiden 'Puig i Soler' / 'Kowalska i Nowak', family 'Doe' and 'Carod
i Rovira'. Controls unmoved -- `... nee Puig i` keeps suffix 'i',
`... i III` suffix 'i III', `... i MA` suffix 'i MA', `... nee i
Soler` declines, and the capital `I` spellings and the delimited
form were never truncated. `remove(conjunctions={"i"})` restores
master's reading.

Two invariants over a new 15,810-row clause grid (4,590 texts; five
heads, all seventeen markers, six bodies, five tails, comma and
none; four policies), sharing one parsed grid per AGENTS.md. THE y
TWIN fails on all 10,540 guarded rows at 0fbcaa0 and at 4665175,
0 here. NO BIRTH-NAME WORD IN THE CURRENT NAME fails on 28,985
tokens at 0fbcaa0 against 22,185 at 4665175 -- the gap is what
this branch added -- and 0 here. INV6's exemption is NOT widened:
1,680 off-switch rows carry a marker and a class letter, 0 put the
letter after the marker. Eight mutations, no survivors.

Four case rows, ten unit tests, three rules.md example lines. All
five differential gates exit 0 with no unexplained diff: one new
rule per ledger plus `Jane Doe nee Puig i III` joining the existing
fix(#436/#437) alternation at the three 2.x baselines, with
_MUST_NOT_MATCH probes and re-recorded _CORPUS_CLAIMS (+4/+1/+1 on
three 1.4.0 rules, 14->15 on the 2.x run rule, verified name by
name). Frames: `Jane Doe nee Smith` 234 unchanged, `... Smith MA`
361 unchanged, `Josep Carod i Rovira` 303 unchanged, `John Smith`
164 unchanged; the moved names fall (371->308, 384->331) and the
controls rise by seven (331->338, 391->399). call_count parse=406.00
facade=443.00 unchanged; `'Puig i ' * k` linear (x1.86/x2.03/x1.98
per doubling, k=50..400); test_properties 25.6s -> 29.9s;
nameparser/_pipeline/_group.py at 100% line coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…, a credential behind a title, and a report that contradicted its reading

A. Case repair's generation guard was the suffix ROLE alone, and the
role says only where a word landed. A plain connective lands in the
suffix field whenever it is a comma form's third part, so
parse("Smith, John, and").capitalized(force=True) gave 'John Smith
And' where 1.4.0, 2.0.0-2.3.0 and the parent 4665175 all give 'John
Smith and' -- the same for ', y', ', e', ', und', ', of' and 'Doe,
Jane, and Jr.' -- and a spliced suffix='de y' gave 'De, Y' against
R4's own Accepted paragraph. The guard is now the ROLE and the
`vocab:suffix` TAG, the two decisions the parse already made. Every
name f76963b was written for is unmoved ('John Quincy Smith i' -> I,
'Carod i' -> I, 'Josep Lluis Carod i III', and 'Josep Lluis Carod v
III' under add(conjunctions={"v"})). ACCEPTED: a spliced suffix="i"
now repairs to 'i', following 'y', because 'i' is connective
vocabulary here; the parsed name is unaffected.

B. `_name_word_beside`'s right bound came from `trailing_start`, the
peel over the pieces as WRITTEN, which a trailing TITLE hides the
suffix run from. 'John Quincy Adams i MA Prof.' read family 'Adams i
MA', suffix empty, no report, initials 'J. Q. A. M.', where 'John
Quincy Adams i MA' reads family 'Adams', suffix 'i MA' and reports
the acronym. New `_pieces.trailing_start_past_titles` runs the peel
and the H5 chain to the fixed point assign already uses, once per
segment and only where such a connective was found. 27 rows on 9
names move over the 43,925-name review grid, every one onto the
off-switch reading. Frame-neutral on names with no generational
connective (John Smith 163, Smith, John 185, Juan Garcia y Lopez 285,
Jane Doe nee Smith 233, all unchanged). NOT the maiden twin: the
clause's blindness to a trailing title is pre-existing and identical
at the parent for the `y` spelling, so it is recorded under #535
rather than fixed here, as is the particle chain's same-shaped bound.

C. classify's `conjunction-or-initial` names a fork between a
connective and an INITIAL; where the parse rolls that letter SUFFIX
it took neither branch. 'JOHN QUINCY SMITH I' carried the report
beside a `suffix-or-name` saying the same token reads as a
generation. Withdrawn in `_assemble`, the one site that sees final
roles and the report list together. 9,806 rows on 1,637 grid names
lose exactly one report and nothing else; on the corpus the only
name touched is 'JOHN QUINCY SMITH I', so the 2.x three-name ledger
rule is intact -- all three role the letter MIDDLE and all three
still report.

D. The stage's class filter carried a `len(tok.text) != 1`, narrowing
P3's clause to one-letter connectives. Dropped: byte-identical over
359,053 parses (the 351,400-parse grid under eight configurations,
2,175 case-table parses under three orders, 5,478 corpus parses under
six), and 'John Quincy Smith og' under add(conjunctions={"og"},
suffix_words={"og"}) now reads suffix 'og' as the rule says. The
three-word carve-out stays single-letter. The `frozen` comment no
longer claims more than freezing does -- a neighbour's join still
absorbs the piece ('Josep Carod Rovira Puig y i' -> family 'Puig y
i', the parent's reading), pinned as a control.

Tests. INV7b over a 21-row suffix-field grid (36 failures at
dc3bdf9, 0 here), INV10 over the connective grid (8,204 at dc3bdf9,
5,078 narrow and 3,126 wide, 0 here), INV6b over the off-switch grid
at no extra parse (944 under a lower-case-unmarking mutant), and the
reach-trim guard, which checks every dropped row against the row it
collapses onto and that every declared variant earns a row. The
off-switch grid gains two trailing-title suffix runs: 66,752 rows
over 16,576 texts, and INV6/INV1-strengthened now fail on 154/105 at
dc3bdf9 where they passed. Four case rows, unit rows for the `og`
class, the trailing-title bound, the withdrawal on both roles, the
ALL-CAPS Catalan recipe, and the v1 off switch through `Constants`.
Property module 33.8s, coverage 100% on every changed module.

E, verified, no code change. `Jane Doe nee MA i Soler` loses its
report because the link no longer ends the clause; the `y` twin
agrees on every field and report, on all 24 grid names. The join
fires on 6 of 1,568 corpus-union-cases names and one is not a Catalan
link ('Henry i of England'). The no-parse paths diverge on 34 of
those 1,568 against 6 at the parent. add(particles={"y"}) moves a
suffix word into a name part on 130 grid names beyond the default
lexicon and loses a title on 4, 71 of the 130 still reporting.

F. Re-measured every count this round moved: INV6's control 2,360 ->
1,480, INV1-strengthened's 960 -> 639, INV7's 11,341 -> 7,860 (6,843
core / 1,017 v1), the blast radius 6/3/50 -> 12/3/56 (22 gain, 34
lose) with the restricted parent-vintage 2/3/46 unmoved, and the
grid's own 12,880 names x eight parsers -> 66,752 rows over 16,576
texts. Three impossible 2026-09-21 dates corrected. The truncated
rules.md#P3 citation in `_group.py` now quotes the whole clause and
names the caller that applies it. The off-switch claim in the release
note and in decisions.md now carries all three exemptions, not one.
AGENTS.md says the design docs that quote a grid's counts move with
it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…mber

`_name_word_beside` answers P3's both-sides condition by walking the
run of connectives beside a link to find the first name word past it,
and both of its callers ask it once per MEMBER of that run -- so a
name holding a run of n links walked it n times and the stage went
quadratic in the run's length. Found while reporting the review round
in b9ed142; `parse()` takes arbitrary strings and that PR's body
claims the join loop is linear, so it has to be.

Every member of one contiguous run has the SAME nearest non-connective
piece on each side, so `_run_neighbours` computes both in two linear
passes over the segment and `_name_word_beside` reads an index instead
of walking to one. Built on the gate the two bounds already share --
only where a generational connective was found, or (through the memo
cell the clause predicate fills on first use) where a clause's walk
reached a suffix piece -- so a name with no such connective is
untouched. The out-of-range sentinels (-1 and len(pieces)) are what
"the run ran out" arrives as, and the bound test already refused them.

Milliseconds, minimum of 7 runs, py3.11:

  shape                          n=100   200    400    800   1600
  "Josep " + "i "*n + "Rovira"
    parent 4665175               0.5    1.0    2.1    4.1    8.3
    b9ed142                      1.4    4.2   15.9   58.6  214.1
    here                          0.6    1.3    2.5    5.1   10.2
  "Josep Carod " + "i y "*n + "Rovira"
    parent 4665175               1.1    2.2    4.4    8.8   17.6
    b9ed142                      2.6    9.0   32.8  113.7  420.3
    here                          1.2    2.4    4.7    9.4   19.1
  "Jane Doe nee Puig " + "i "*n + "Soler"
    parent 4665175               0.6    1.1    2.1    4.3    8.5
    b9ed142                      1.3    4.0   15.2   59.3  213.4
    here                          0.6    1.1    2.1    4.2    8.4
  "Josep " + "Carod i "*n + "Rovira"   (runs of one; linear already)
    parent 4665175               1.1    2.2    4.4    8.8   17.6
    b9ed142                      1.2    2.4    4.9    9.7   19.5
    here                          1.2    2.4    4.8    9.7   19.3

b9ed142 grows 3.65-3.89x per doubling on the first three; here they
read 1.97-2.03x, at the parent's absolute cost.

BYTE-IDENTICAL, 1,748,904 parses a side and the same SHA-256: 145,742
names -- the differential corpora, the case table, all three property
grids, and a generated run-heavy set (runs of 1-6 connectives mixing
i/y/e/and/of, at the head, middle and tail, with suffix and title
tails, comma and no-comma, and inside maiden clauses, in three
casings) -- under three lexicons (default, add(conjunctions={"v"}),
remove(conjunctions_ambiguous={"i"})) and four policies (default, both
family-first orders, strict comma).

GUARDED TWICE, because one instrument cannot reach both callers.
tests/v2/test_benchmark.py gains a thirteenth `_SHAPES` row, `link_run`
("i Und " -- mixed case, since a one-case name reads the letter as an
initial and reaches nothing), which reads 4.02-4.19 here and 13.5-14.2
at b9ed142 across three repeated runs, against a 6.0 bound. The
clause caller needs a PREFIX its table cannot express -- "nee i Und "
repeated never reaches the clause rule at all, measuring the identical
3.92/4.07/4.12 on both trees -- so it gets
`test_a_clause_link_run_does_not_cost_quadratically`, which builds its
own input, asserts the clause still holds the run, and counts frames:
876 -> 2,652 here (3.03x) against 1,125 -> 6,741 (5.99x), bound 4.5.

THE PRICE, recorded rather than hidden: answering for the whole segment
costs a short name one call plus `_is_conj_piece` for the pieces the
walk never reached. `Josep Carod i Rovira` 304 -> 307 frames and `Jane
Doe nee Puig i Soler` 307 -> 312, against 6,741 -> 2,652 for that
second name with a run of 64. `tools/perf/call_count.py` is unmoved
(parse=406.00 facade=443.00) and so are `John Smith` 163, `Smith,
John` 185, `Juan Garcia y Lopez` 285 and `Jane Doe nee Smith` 233.

Mutation-checked, each arm by a named test: both sides reading the
left array fails test_a_connective_with_nothing_to_its_right_does_not_
join, both reading the right fails test_a_leading_title_on_the_left_is_
no_name_word, either pass losing its `not` fails test_a_connective_
piece_counts_toward_the_carve_outs_total, and rebuilding per call
instead of once fails the new clause guard. SWAPPING the two arrays
outright is an equivalent mutant and nothing fails -- both callers AND
the two sides, so the conjunction cannot see which array answers which.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s mark

No behavior change: every reading over 157,188 names (the corpora, the
case rows, the property grids and 146,398 generated names) under 24
lexicon x policy configurations, plus the facade and the no-parse
paths -- 3,931,700 rows -- is byte-identical to 6048eb5. Reference
frames hold at 406 / 443, all eleven per-name probes are unchanged,
and every run shape still doubles at 2x.

- _group: `_link_joins_inside_the_clause` asks whether the piece is a
  connective before it fills the neighbour memo, so a clause ending at
  an ordinary credential never builds it ('Jane Doe nee Smith PhD'
  346 -> 340 frames); `peel_start` moves down beside `lo`, so the
  clause's two bounds are computed and explained together.
- _render: `_cap_word` names its guard (`generation`) and flattens to
  three arms under one `not generation`; the particle arm asks for a
  middle or family role, so the wider guard is equivalent.
- _post_rules: one `is_conj` local, connectives collected in one place.
- Comments across _group, _facade, _types compressed to what the code
  does, the measurement and the reason.
- One comment claim corrected: 'PhD nee - i Jones' does not witness
  "a core is below `lo`" -- delimiter cores exist only in tail
  segments, so that dash is an ordinary piece and IS the name word
  that keeps the link (maiden '- i Jones', measured).

Declined, recorded for a later round: folding both sides into one
`_name_word_beside` call saves a frame per connective but moves frame
figures quoted in test_benchmark.py and decisions.md, which this pass
does not touch. Rejected by measurement: a shared two-sided helper
(+1 frame per generational connective).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug docs Documentation fixes and updates enhancement

Projects

None yet

1 participant