Conversation
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>
Codecov Report✅ All modified and coverable lines are covered by tests. 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. 🚀 New features to boost your workflow:
|
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #397. Closes #461.
#397 — the Catalan and Polish surname link joins
ijoins the defaultconjunctions, marked inconjunctions_ambiguousbesidee.Josep Carod i Rovira,Antoni Gaudí i Cornet,Jan Kowalski i NowakCarod i, familyRoviraCarod i RoviraJosep Lluis Carod i RoviraLluis Carod iLluis, familyCarod i RoviraCarod i Rovira, JosepCarod Rovira, suffixiCarod i RoviraJOSEP CAROD I ROVIRA,josep carod i roviraconjunction-or-initialJosep 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.Two rule changes make the word work (adding it alone fixed only names of five or more words):
iis also the roman numeral, so it was excluded from the count it needed) — but only where it is placed to join.The off switch, and the invariant the tests enforce. A caller who wants none of this removes
ifromconjunctions, 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 Smithforced keepsi). Property tests hold the parser to exactly that: anithat 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 incustomize.rst, tested on ALL-CAPS names).One-case names, stated plainly.
ibeing marked means a name written wholly in one case reads it as an initial and reports, exactly asedoes since #383. SoJOHN I SMITHnow reports, and in ALL-LOWER names the initial reading moves fields where master read a lower-caseias the generation — each now reads as its all-caps twin already did:rovira, i→ giveni;maier, amy i, jr.→ middlei(asMaier, Amy I, Jr.);john smith i jr. None is in the corpus. Recorded underP3in decisions.md.Accepted:
Carod i(two words) keeps the generation reading (2.0–2.3 parity; 1.4.0 read familyi);Henry i of Englandreads as a connective run, theDuke of Edinburghshape; under a custom lexicon listingyas a particle,Juan Velasquez y Garcianow 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.John and Jane SmithJ a J. S.J. a. J. S.J. J. S.Duke of Edinburgh/John & JaneD o E./J & J.D. o. E./J. &. J.D. E./J. J.Juan y/Juan de yJ.J.(butfamily_baseisy)J. y.Juan y GarciaJ. y. G.J. G.J. y. G.(restores 1.4.0)Juan Velasquez y Garcia/Jon Dough andJ. V. G./J. D.parse().initials()andHumanName.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#R4now 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 IIIread familyCarod 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)gaveJohn Smith iwhere every release gaveJohn 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
iis the roman numeral, soJane Doe nee Puig i Solerwas cut to maidenPuigon master — and on this branch the leakedi Solerthen JOINED the current surname (familyDoe 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: maidenPuig i Soler, as theyspelling always read.Jane Doe nee Puig iand… i IIIare unchanged. Two invariants pin it: theytwin (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, and→And; 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.→ familyAdams i MA, report lost; the bound now comes from the reading that sees past trailing titles); andJOHN QUINCY SMITH Icarried aconjunction-or-initialreport 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
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
_group.py,_render.py,_post_rules.py,_types.py.fix(#397) NOT WANTEDis rewritten as the accepted two-word boundary rather than deleted.🤖 Generated with Claude Code