Skip to content

fix(render): initials() honors the folded-word order the family field applies (#408) - #465

Merged
derek73 merged 12 commits into
masterfrom
fix/408-initials-honor-the-fold
Aug 30, 2026
Merged

fix(render): initials() honors the folded-word order the family field applies (#408)#465
derek73 merged 12 commits into
masterfrom
fix/408-initials-honor-the-fold

Conversation

@derek73

@derek73 derek73 commented Aug 30, 2026

Copy link
Copy Markdown
Owner

initials() read a name in a different order than the fields of the same name.

Closes #408

The defect

Two rules fold words into the family and render them before it — O3's middle_as_family, and P6's tussenvoegsel attachment after a family comma. _types._text_for honors that: it collects FOLDED_TAG carriers and returns folded + parts. _render.initials() walked tokens_for(role) in written order and never applied the reorder, so the two views disagreed about one parse:

parse("der, y van")     family 'van der'    initials 'y. d. v.'    facade 'y. v. d.'

mechanisms.md#RENDER-HONORS-THE-PARSE already named this as an instance of its second shape — a view and a field disagreeing about the same parse — so the design was settled before implementation and is not re-argued here.

It restores v1 parity rather than deviating from it

This is the opposite of what the issue assumed, and it is the finding that made the change cheap.

v1 had this feature as CONSTANTS.middle_name_as_last, which _config_shim maps to middle_as_family. Measured on the released 1.4.0 wheel, at the default order:

movers come into exact 1.4.0 agreement move away from it
default order 1 0 0
middle_as_family 71 54 0

Of the 17 that match neither, 14 differ only in v1's per-*_list-element initials granularity (V G. against V. G.) and all 14 come into 1.4.0's letter order after the change, none before. Two differ because the 2.x parse itself differs — Smith, John V. is #432 and 남궁민수 지훈 is W1. The fifteenth is der, y van, where 1.4.0 contributes nothing for an all-particle family, which is R3's own decided clause.

v1 has no general name_order, so the family-first orders have no v1 reference in either direction.

Reach

660 of 6564 parses move — 1094 corpus names × three name_order values × middle_as_family off/on:

default order           1 / 71
FAMILY_FIRST            1 / 294
FAMILY_FIRST_GIVEN_LAST 1 / 292

The population matters more than the count. The shape an order change can be observed in is a contributing folded token standing after a contributing unfolded one of the same role. At the default order the corpus holds exactly 1 such name without the option and 72 with it — so the movers are 1 of 1 and 71 of 72, a near-total hit rate over a corpus carrying one instance of P6's half, not a reach near zero.

The differential gate cannot see any of this. compare.py compares the seven role fields plus _ambiguities; initials appears nowhere in it. Gate output is byte-identical before and after, including all 67 / 58 / 51 per-heading counts. No ledger rule was added and none was needed — the same blindness decisions.md#R4 records for capitalized().

Review

Four agents. Nine Important findings, no Critical, all fixed. Three are worth naming because they are the same failure shape at three depths.

Fixture arity, three times over. A partition over a group with fewer than two elements is the identity, so an under-populated fixture asserts the behavior and pins nothing.

  1. Draft 1 gave MIDDLE one token — skipping the partition for MIDDLE passed the entire suite.
  2. Draft 2 gave FAMILY zero, in a test named for every role — skipping it for FAMILY passed that test.
  3. Draft 3 fixed the roles, but the folded sub-group the partition builds was Ahmad Ali — two elements that both initial to A., so reversing the folded half alone passed all 428 relevant tests.

Recorded as mechanisms.md#TWO-ELEMENT-GROUPS: count the elements, and count the ones the assertion can distinguish.

A recompute recipe that measured nothing. decisions.md#R3 said to compare initials() "against a folded-first partition of tokens_for(role)" — which is what initials() now is. On the shipped tree that yields 0 differing parses; the 660 comes from comparing against the pre-change written-order rendering. The inert-measurement shape inside the sentence written to prevent it, on a count the gate cannot check. The recipe now names the comparator and reproduces 660.

A claim asserted in three places and tested nowhere. _render.initials, its sibling test and mechanisms.md#FOLDED_TAG all say the pipeline puts FOLDED_TAG on FAMILY tokens alone — the entire justification for the GIVEN and MIDDLE arms being uniformity rather than reachable behavior. True (0 of 1094 names carry it elsewhere) and unguarded, so a rule that ever folded into another part would falsify all three silently. Now pinned, and verified by mutating a producer to fold into MIDDLE.

Also fixed: a 2.2.0 - Unreleased bullet six lines above this one still told readers the two APIs order initials differently and that #408 was open, with values false as shipped.

Verification

  • 6125 → 6129 passed; mypy, ruff, sphinx doctests and README doctest clean.
  • Gate green at all three baselines, byte-identical to master's output.
  • corpus_rules.jsonl unchanged at 241 — R3's new example line reuses an input already present.
  • Completeness checked by an independent invariant rather than a reimplementation: each group's initials letter sequence must be a subsequence of that group's field word initials. 660 violations before, 0 after.
  • Every mutation named in decisions.md#R3 reproduces, each failing at its own role's assertion.

The rule this needed, amended

AGENTS.md admitted two provenance sources for a quantified release bullet — the gate's classified summary, or verification against a rules.md example. Neither can reach a render view: the gate's output is byte-identical across this change, and an example line witnesses an output without counting anything. This bullet named a third source in prose with no rule admitting one, so the rule now admits it — a recompute recipe kept with the design entry the bullet cites — and carries the failure that recipe hit, since naming the comparator and running the recipe as written are what stop it recurring. Cross-version numbers stay dated snapshots under Counting claims.

🤖 Generated with Claude Code

derek73 and others added 12 commits August 30, 2026 00:53
O3's fold and P6's attachment tag rather than move a token, and
`_types._text_for` partitions on that tag for every role it renders --
so `parse("der, y van")` reads family 'van der'. `initials()` walked
`tokens_for(role)` in written order and never asked, so a view and the
field beside it gave two orders for one parse: initials 'y. d. v.'.

Mirrors `_text_for`'s partition, per role rather than scoped to
FAMILY. Both producers re-role to FAMILY, so the GIVEN and MIDDLE arms
are unreachable today; they are there so the two views cannot diverge
again, the same uniformity `_post_rules`' UNJOINED_TAG loop takes.

Measured over the 1094-name deduped corpus, 6564 parses across three
name_order values x middle_as_family off/on: 660 move. At the default
order it is 1 without middle_as_family and 71 with it, and the change
is strictly parity-restoring -- 54 of those 71 return to exactly what
the released 1.4.0 wheel gives and none moves away from it. The
facade's initials had been folding all along, through its own *_list
views, so the core view was out of step with the field, the facade and
v1 at once, with nothing in the suite touching it.

`initials()` is not a compared surface, so the gate cannot see this:
run at all three baselines on the pre-change tree and on this one, the
output is identical to the byte. No ledger rule was added or needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All five were measured on the branch, and three were errors in my own
measurements rather than in the prose reporting them.

1. The release-log bullet opened on `HumanName("der, y van")`, which
   never moved -- the facade folds through its own `*_list` views and
   moves for 0 of the 1094 corpus names. The value that moved is
   `parse(...)`, the 2.0-API view, which is also the bullet's own
   argument four sentences later. Corrected, and the facade's
   invariance is now stated as measured rather than asserted.

2. The 17 names matching 1.4.0 neither before nor after split 15/2,
   not 14/3: `der, y van` parses BYTE-IDENTICALLY to 1.4.0 under
   `middle_as_family`, and I had carried its field diff over from the
   other policy. It also disagrees for a different reason than the
   14 -- 1.4.0 contributes nothing for an all-particle family where
   2.x contributes its words, which is R3's own decided clause, not
   an unexplained residue. `남궁민수 지훈` is the hangul surname split
   (W1), not #379/#404 or #432.

3. `test_initials_folds_in_every_role_it_renders` asserted the MIDDLE
   arm and pinned only GIVEN: the hand-built name had ONE middle
   token, and a one-element partition is the identity. Two tokens per
   role now, and each arm is separately mutation-checked -- skipping
   the partition for GIVEN, for MIDDLE, or for FAMILY each fails.

4. R3's `history:` still pointed at `decisions.md#R2`, so the entry
   arguing its new order clause was unreachable from the rule. It
   points at `#R3` now, and the entry carries the back-pointer.

5. "One name moves at the default policy" carried no denominator. The
   population is one: exactly one corpus name has a contributing
   folded token standing behind a contributing unfolded one. Stated
   at both sites, with the 71-of-72 figure for the folded policy.

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

The entry named one mutation; finding 3 showed it left the MIDDLE arm
unpinned. Records the four that run now, and the general shape -- a
test pinning a partition needs two elements in every group it claims,
or the claim rides on an identity.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e out in

ParsedName.initials' docstring IS the reference modules.html renders,
so a docs sweep that stops at the .rst files misses half the page
(AGENTS.md's release checklist, step 0). The order clause was in
_render.initials, which autodoc does not render.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The #408 test's one-token-per-role fixture made its MIDDLE arm's
mutation a no-op, and the FAMILY-scoped mutation that did fail read
as proof the arms were pinned. Recorded as a Verification shape,
since the failure arrives through the fixture rather than the
predicate and the existing entries all address the predicate.

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

The comment referred to 'the mutation below', which lives in
decisions.md rather than in this file. Cites
mechanisms.md#TWO-ELEMENT-GROUPS under the excerpt discipline
instead -- and the citation test caught the first attempt, which had
paraphrased the em dash away.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…E, not one per role

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The test named for every role it renders carried two GIVEN and two
MIDDLE and no FAMILY, so skipping the partition for FAMILY passed it --
caught only by its siblings and by R3's example line, neither of which
is about per-role application. Verified by mutation before and after:
the skip-FAMILY mutation now fails this test and did not.

Same miss as the first draft's, one role apart. A zero-element group
is the identity as surely as a one-element group and reads even less
like a gap, which is how it survived the round that had just named
the one-element form. decisions.md and mechanisms.md#TWO-ELEMENT-GROUPS
both described the fixture wrongly and are corrected; the mechanism's
only worked example was certifying as fixed the gap that survived.

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

Two findings from the branch review.

The partition BUILDS a group, and that group needs the two-element
treatment as much as the role group does. The only fixture with a
two-element folded run was 'Hassan, Mohamad Ahmad Ali', whose run is
'Ahmad Ali' -- both initial to 'A.', so reversing the folded half
alone passed every test in the file. Verified before and after:
the reversal mutation now fails test_initials_order_folded_words_
first_like_the_family_field and previously failed nothing.

Third occurrence of the arity shape on this branch, one level down
each time: a one-element role group, then a zero-element one, now a
two-element group whose members the assertion cannot tell apart.
mechanisms.md#TWO-ELEMENT-GROUPS says count the elements; this one
says count the ones the assertion can DISTINGUISH.

Separately, the #385/#402 bullet six lines above the #408 bullet, in
the same Unreleased section, still told readers the two APIs order
initials differently and that #408 was open. Every clause of it was
false as shipped: the values agree at 'y. v. d.', the 87 constructed
inputs are 0, and #408 closes below it. No count survives the fix, so
none is carried forward.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
_render.initials, its sibling test and mechanisms.md#FOLDED_TAG all
say the pipeline puts the tag on FAMILY tokens alone -- which is why
the GIVEN and MIDDLE arms of the partition are uniformity rather than
reachable behavior. Measured true (0 of 1094 corpus names carry it
elsewhere) and tested nowhere, so a rule that ever folded into another
part would falsify all three silently and turn initials()'s 'would
otherwise reopen #408 there' into a live gap with nothing to fail.

Verified by mutation: re-roling one producer's fold to MIDDLE fails
this guard. It also asserts its own floor, so it cannot pass by
exercising nothing.

Three comment overclaims fixed with it, all the reach-versus-rule
shape this branch keeps producing: 'the one name the DEFAULT policy
moves' is the one CORPUS name (constructed ones move too -- 'de la, y
van', 'der, e van'); skip-FAMILY was caught by the siblings AND R3's
example line, not the siblings alone; and draft 1 carried both arity
misses, not one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five findings from the docs review of this branch. Each was
re-verified at HEAD before editing; all five still held.

decisions.md#R3's recompute recipe measured the implementation
against itself. "Compare initials() against a folded-first partition
of tokens_for(role)" is what initials() now IS, so on the shipped
tree the recipe yields 0 differing parses, not 660 -- the
inert-measurement shape inside the sentence written to prevent it,
and the only recompute path the repo offered for the release log's
counts too. The recipe now names the comparator that produces the
number (the pre-change rendering: the same contributing tokens left
in WRITTEN order) and states that the skip filter comes first,
because dropping it counts every skipped word as a difference. All
four numbers measured 2026-08-30: 660 of 6564 with the filter and
written order, reproducing 1 / 71, 1 / 294, 1 / 292; 0 for the old
wording; 1546 for written order without the filter; 1018 for the
folded partition without it.

mechanisms.md#RENDER-HONORS-THE-PARSE called the fix "strictly
parity-RESTORING", which overstates in two directions. 588 of the
660 moving parses are under the two family-first orders, which v1
has no equivalent for, so nothing there is restored or broken --
decisions.md#R3 already says this. And the entry's own lead example
is a counterexample: parse("der, y van") gives y. d. v. before,
y. v. d. after, and 1.4.0 gives y. (measured on the released wheel).
The 54-of-71-and-none-away claim is kept and scoped to the
population it was measured over.

rules.md#R3's new order clause rested on a false premise -- "initials
abbreviate a field" -- which the rule's own first sentence and first
example line contradict: the family FIELD of "Dr. Juan Q. Xavier de
la Vega III" is de la Vega and its initials are J. Q. X. V. #461 is
open and is exactly that membership question, so a normative "the two
never disagree" would be the authority for forcing initials() to
match family_base -- the reading measured and backed out on the
previous branch. The clause now rests on order alone.

mechanisms.md#FOLDED_TAG claimed the consumer list "has failed there
twice in opposite directions". The second never shipped: the revise
strip arrived with Parser.revise in the same commit as the test that
pins it, and no issue, decision entry or test names a released defect
of that shape. Now one shipped failure and one hazard. Its "Lives in."
roster also gave that site as a bare method; it is
nameparser/_parser.py:187, and the roster is what a reader walks when
adding a view.

docs/release_log.rst's #408 bullet carried counts from neither source
AGENTS.md sanctions -- the bullet itself says the gate cannot see
initials(), and R3's example line witnesses the order without
counting. No count is dropped: each carries argument the bullet needs.
Instead the bullet now says where each comes from -- what moves is
recomputed by decisions.md#R3's now-working recipe, while the 1.4.0
comparisons and the facade sweep are dated snapshots nothing in the
repo re-derives.

Verified: 6129 passed / 156 skipped / 9 xfailed; mypy, ruff, sphinx
doctests and the README doctest clean; corpus rebuild 1094 / 241 with
no diff; gate 229 / 194 / 102 with unexplained: 0 at 1.4.0 / 2.0.0 /
2.1.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The rule named two sources for a quantified release bullet: the
differential gate's classified summary, or verification against a
rules.md example. Neither can reach a claim about a render view. The
gate compares the seven role fields and _ambiguities, so a change to
initials() or capitalized() leaves its output identical to the byte,
and an example line witnesses one output without counting anything.

#408's bullet needed counts of exactly that kind, so it named a third
source in prose -- a recompute recipe kept with decisions.md#R3 -- with
no rule admitting one. Admit it, and carry the failure that recipe hit:
its first draft compared initials() against a folded-first partition,
which is what initials() now IS, so it reproduced 0 against a claimed
660 while being the only stated provenance for the number. Naming the
comparator, and running the recipe as written before shipping the
bullet, are what stop that.

Cross-version numbers stay dated snapshots under Counting claims.
@derek73 derek73 added this to the v2.2 milestone Aug 30, 2026
@derek73 derek73 added the bug label Aug 30, 2026
@derek73 derek73 self-assigned this Aug 30, 2026
@codecov

codecov Bot commented Aug 30, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.65%. Comparing base (805bcf8) to head (ab5af8e).

Additional details and impacted files
@@           Coverage Diff           @@
##           master     #465   +/-   ##
=======================================
  Coverage   98.65%   98.65%           
=======================================
  Files          45       45           
  Lines        3193     3194    +1     
=======================================
+ Hits         3150     3151    +1     
  Misses         43       43           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@derek73
derek73 merged commit caaed90 into master Aug 30, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

initials() ignores the folded-word ordering that the family field applies (der, y van gives y. d. v. vs the facade's y. v. d.)

1 participant