Skip to content

fix(#528): HumanName.initials() reads the parse's connective tags - #529

Merged
derek73 merged 11 commits into
masterfrom
claude/528-facade-initials
Sep 15, 2026
Merged

derek73 merged 11 commits into
masterfrom
claude/528-facade-initials

Conversation

@derek73

@derek73 derek73 commented Sep 14, 2026

Copy link
Copy Markdown
Owner

Closes #528.

HumanName.initials() now reads "is this word a connective" from the parsed token's tags wherever a token backs the word — a conjunction-tagged word contributes no initial, an initial-tagged or untagged one contributes — and falls back to the vocabulary and shape only for spliced raw text (hn.middle = ..., replace(), the v1 pickle path), through the same _render._reads_as_conjunction that R4's case repair uses. Particles stay a live vocabulary lookup, as _cap_word keeps them. HumanName("john e smith").initials() gives j. e. s. where 1.4.0 through 2.3.0 gave j. s.; JUAN Y GARCIA gives J. G. where 1.4.0 and 2.3.0 gave J. Y. G. (2.0.0 through 2.2.0 gave J. G. for the #462 reason). Mixed-case names are untouched; maria y lopez stays m. l. because y is outside the marked set. The facade and the core now agree on 1173 of the 1174 corpus names; the one left, Ph. D., John, is the per-word grouping of a joined run (fix(initials-per-word)), not this issue's.

Three accepted consequences, recorded at decisions.md#R3: the connective answer is fixed at parse time for a token-backed word, so a C.conjunctions edit after parsing takes effect on the next full_name assignment, as capitalize() already behaves; a pickled facade re-loaded takes the fallback, so its initials can diverge from the live parse, again as capitalize() already does; and one stated private break — a v1 subclass overriding _process_initial with the two-argument signature now raises TypeError from initials(), and the remedy is to accept tokens= and forward it to super() (widening the signature alone returns "" for every group, measured and pinned as the negative control).

Measured: six one-case corpus names' facade initials move for this reason; two of them also move a role against 1.4.0, so four carry the new 1.4.0 _initials rule, and the equal-fields contest with the connective-run rule is pinned by file order and _CROSS_RULE_WINNERS. At 2.0.0 through 2.2.0 nothing new diffs (the e-names hide behind their _ambiguities report; JUAN Y GARCIA's two-cause row became single-cause). Design review (design-docs-reviewer) ran on the docs/design diff; its findings landed in the docs commit, including a measured facade witness that retracts the old "nothing observable" conclusion about _INITIAL versus the pipeline in a no-initials script for the facade view.

Three commits: the fix with its tests, ledgers and guards; the ledger comments corrected to what was measured; the record.

Gate: 422 / 349 / 263 / 126 / 10 intentional at 1.4.0 / 2.0.0 / 2.1.0 / 2.2.0 / 2.3.0, 0 unexplained, 0 radar unclassified. Full suite 7964 passed; sphinx doctest 250 / 0; sphinx-build html clean; mypy and ruff clean.

🤖 Generated with Claude Code

derek73 and others added 3 commits September 13, 2026 21:17
The v1 facade decided "is this word the connective" from the lexicon
plus v1's initial shape on the part's raw text, where the core's
initials() reads the tag the parse recorded. #383/#479 gave
rules.md#P3 a fork the shape cannot see -- in a name written wholly in
one case a marked 'e' is an initial and an unmarked 'y' joins -- so
the two views of one parse disagreed about the same letter.

_list_for becomes one walk with two views: _list_tokens_for yields
each list element's backing tokens, and the string view is built from
it, so the element boundaries the walk exists to hold (a "joined"
continuation, a folded middle) cannot drift between them.
_process_initial keeps its v1 signature for direct callers and gains
an optional `tokens`; with tokens each word asks the tag, falling back
to _render._reads_as_conjunction -- case repair's own R4 helper -- for
a token carrying UNCLASSIFIED_TAG, which is text a parse never read.
_is_conjunction goes with its only caller.

Measured over the 1174-name corpus glob: six names moved and the two
views now agree on every name but 'Ph. D., John', whose divergence is
the pre-existing Ph. D. merge. 'john e smith' gives j. e. s. where
1.4.0 through 2.3.0 gave j. s.; 'JUAN Y GARCIA' gives J. G. where they
gave J. Y. G.

Accepted cost, the R4 shape: for a word backed by a token the answer
is fixed at parse time, so a C.conjunctions edit takes effect on the
next full_name assignment, as capitalize()'s already did. An unpickled
name is spliced text throughout and takes the vocabulary fallback,
which capitalize() has done since __setstate__ stamped the tag. And a
stated private-method break: a v1-shaped subclass overriding
_process_initial(self, name_part, firstname=False) now raises
TypeError from initials(), since initials() passes tokens= -- such an
override must accept it. Pinned by a test and named in the method.

Ledgers: one new _initials rule at 1.4.0 over the four names whose
roles do not move -- 'john e smith', 'john e jones', 'jones, john e',
'JUAN Y GARCIA' -- written ahead of fix(initials-per-word)'s
connective rule, whose equal `fields` make file order the whole
decision on the two names both reach; _CROSS_RULE_WINNERS and
_RECORDED_DIFFS pin that. At 2.0.0-2.2.0 fix(#462)'s two-cause
paragraph becomes single-cause, the facade half having closed; at
2.3.0 both surfaces now move together. The prose that said the facade
does not follow the tags is corrected where the sweep found it,
tests/v2/cases.py's two #383/#479 notes included. Five gates at 0
unexplained.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Quality-review fixes on 919644b's ledger and guard prose, comments
only. The #528 rule's comment said "'j. s.' -> 'j. e. s.' on the
three e-names", which is true of one of them: measured against the
1.4.0 wheel, 'john e jones' and 'jones, john e' go 'j. j.' ->
'j. e. j.'. Each of the four is now stated on its own.

"Six corpus names move `HumanName.initials()`" was an undated
standing count and false read literally -- 'e and e' and 'juan
garcia y lopez' move here too, on the 2.0.0 per-word grouping. It
becomes a dated, scoped statement in both places it is written: six
one-case names move for the TAG reason, measured 2026-09-13 by
driving the facade before and after the change, two of them
('jose e maria santos', 'JUAN GARCIA Y LOPEZ') moving a role as well
so that `_initials` never enters their diff.

The equal-`fields`/file-order argument and the role-mover exclusion
were each written twice inside expected_since_1.4.0.toml. The block
header keeps both copies and the rule's comment points at it; the
copies in compare.py and tests/v2/test_ledger_guards.py stay, being
the cross-file convention.

Also: _CROSS_RULE_WINNERS now gives one property per uncontested
name ('jones, john e' by the trailing-whitespace requirement,
'JUAN Y GARCIA' by the case) rather than both at once;
_CORPUS_CLAIMS' pronoun pointed at three rules where the two shared
names are the connective rule's alone; the 2.0.0 ledger says "the
wheel's FACADE gives 'J. G.'", the wheel's core giving 'J. Y. G.';
the "a connective run initials" roster's third stacked paragraph
folds into a dated correction of the second; _MUST_NOT_MATCH said
"the first four are MIXED-CASE" where a ninth probe had made it
five; and cases.py's run-on splits.

_RECORDED_DIFFS' two new rows move below #498's block so the three
cohorts read down the dict in the order the PROVENANCE note tells
them, and that note goes 45 -> 47 with #528's two named and dated.
All five gates re-run at 0 unexplained and no moved shape.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
decisions.md gains the R3 entry for #528: the facade's initials view
reads the parse's tags, in R4's shape -- a word backed by a token
asks its tags, a token carrying UNCLASSIFIED_TAG was read by no parse
and the vocabulary answers through the same helper _cap_word calls.
Its two accepted costs are measured and pinned: the connective answer
is frozen at parse time, so a C.conjunctions edit waits for the next
full_name assignment, and an unpickled name is spliced text
throughout and takes the fallback, both as capitalize() has behaved
all along. The stated private-method break is recorded with the
remedy that actually works -- an override of _process_initial must
ACCEPT `tokens` AND FORWARD it, since widening the signature alone
leaves the override reading the token path's empty name_part and
initials() comes back "" without raising. Measured, and now pinned by
test_an_override_that_forwards_tokens_keeps_working with the
widen-only spelling as its negative control. The entry also carries
the one corpus name whose two views still differ ('Ph. D., John', the
2.0.0 per-word merge and not this change) and the ledger picture at
all five baselines. P3's facade paragraph, which deferred this to a
follow-up, is closed out in place and points here.

rules.md#R3 and #R4 now distinguish the TWO initials views. The two
agree on which words initial in a parsed name; what still differs is
GROUPING, the facade initialing a joined run as one element. And on a
spliced field the facade falls back on BOTH questions where the
parsed name's own view falls back on neither -- the connective
question through case repair's helper, the particle question through
a live lookup, so a family spliced to "de la vega" initials "j. v."
on one view and "j. d. l. v." on the other.

mechanisms.md: FOLDED_TAG credits the walk that does the folded-first
ordering rather than the string view built on it; VOCAB-TAGS adds the
facade as a tag consumer, a module a sweep stopping at _render.py
misses; RENDER-HONORS-THE-PARSE gains the second reader of the
vocabulary fallback and RETRACTS, for the facade view only, its old
"nothing observable" conclusion about the _INITIAL sync divergence --
with `conjunctions.add("太")`, 'Wang Chen 太. Li' initials 'W. C. L.'
parsed and 'W. C. 太. L.' with the identical family text spliced in,
while capitalized(force=True) shows nothing either way. Accepted
rather than repaired, the remedy differing by view: revise() for the
parsed name, a full_name reassignment for the facade, which has no
revise.

usage.rst's twin sentence is scoped to the parsed name's own views.
docs/release_log.rst gains the 2.4.0 Fix bullet for #528 -- with the
forwarding remedy, and with the 2.0.0-2.2.0 values stated rather than
folded into "1.4.0 through 2.3.0", which is false for the two capital
names -- and the #527 bullet's now-wrong facade sentence is corrected
in place. tools/differential/README.md's "32 of the 45 today" becomes
the dated form; the rows are 47 since #528.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@derek73 derek73 added this to the 2.4 milestone Sep 14, 2026
@derek73 derek73 added the bug label Sep 14, 2026
@derek73 derek73 self-assigned this Sep 14, 2026
@codecov

codecov Bot commented Sep 14, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.69%. Comparing base (338daf7) to head (5eef00d).

Additional details and impacted files
@@           Coverage Diff           @@
##           master     #529   +/-   ##
=======================================
  Coverage   98.68%   98.69%           
=======================================
  Files          45       45           
  Lines        3347     3366   +19     
=======================================
+ Hits         3303     3322   +19     
  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 8 commits September 14, 2026 02:49
- initials_separator was unguarded on the live token path: the two
  existing "Van Berg" tests exercise _process_initial's direct-string
  branch only. Pin it on HumanName.initials()/initials_list() over
  "Ph. D., John" instead, and note on the two direct-call tests that
  they cover the string path alone.
- Add "john e jones" and "jones, john e" to the one-case-fork test:
  they back the 1.4.0 ledger rule, and the comma form is the shape
  where the connective-run regex never reaches the trailing "e".
- test_list_tokens_for_carries_the_list_view_s_own_elements asserted
  the string view against _list_for(member) and that every group is
  truthy -- both true by construction (_list_for IS that join; an
  empty groups list is vacuously "all true"). Assert the string view
  against its own per-case literal instead, and the broader sweep
  against a second call for determinism.
- Rename test_initials_of_an_unpickled_name_ask_the_vocabulary_too:
  copy.copy/copy.deepcopy go through the same __getstate__/__setstate__
  hooks as pickle, so a copied name takes the identical vocabulary
  fallback. Measured and pinned alongside the pickle case.
- Fix two stale comments: the mixed-case-controls comment covered a
  one-case control ("maria y lopez") as if it were mixed-case, and
  "joined" is healed by _list_tokens_for now, not _list_for (which is
  only the string view built from that walk).
- Correct the corpus count: 1173 non-empty names, out of 1174 distinct
  names in the glob (one is empty), not "1173 names".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- _facade.py/_render.py: two stale "_list_for's heal" / "text the
  parse never classified" comments corrected to name _list_tokens_for
  and the actual reader (a spliced field or a direct string call);
  the #232 doubled-space paragraph moved down into the tokens-is-None
  branch it now describes, and reworded to say so.
- The accepted pickle cost is really a __getstate__/__setstate__ cost:
  copy.copy and copy.deepcopy take it too, measured. Widened the
  ACCEPTED COST paragraph in decisions.md#R3, the release_log.rst
  2.4.0 Fix bullet, and the one _facade.py comment this PR added that
  named the pickle path alone (usage.rst and mechanisms.md's existing
  pickle prose predates this PR and is untouched).
- decisions.md#R3's STATED PRIVATE-METHOD BREAK paragraph undersold
  its own scope: "the two initials values above" -> "the six names
  above", matching what the rest of the entry already says.
- rules.md#R3/#R4: the particle question is not a fallback for the
  facade's initials view -- _is_particle is a live vocabulary lookup
  for every word, backed or spliced alike, and the facade never reads
  a particle tag. Reworded both paragraphs to say so, and disambiguated
  "in both views" (which now reads as {parsed, facade} next to the new
  facade paragraphs) to "in both of the parsed name's views" -- there
  is no HumanName.revise().
- decisions.md#P3: this PR had rewritten a dated entry that already
  landed in #527, instead of appending a new one. Restored the
  original "ACCEPTED, AND DEFERRED TO A FOLLOW-UP ISSUE" bullet
  verbatim and appended a new 2026-09-13 #528 paragraph carrying what
  the rewrite said, scoping "all along" to the window it actually
  covers (2.3.0 gave "John e Smith").

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The design-docs review of the previous commit found three prose
slips. The restored 2026-09-13 P3 bullet had dropped the sentence
its own amendment points back to; it is byte-exact against master
again. The #528 entry's "six names above" pointed at the wrong
names, so the six are now listed. R3's enumeration of where the
facade's initials view falls back said pickle only, while the
decision entry and the release note already said pickle or copy.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
_initials_lists called self._process_initial("", firstname=firstname,
tokens=group) -- the empty placeholder never mattered on the token
path, since _process_initial ignores name_part whenever tokens is
given. But a v1-style subclass that widens _process_initial's
signature to swallow `tokens` (`**kwargs`, or an unforwarded
`tokens=None`) without forwarding it takes the STRING path in the
super() call, reading that same "" for name_part -- so initials()
came back "" for every group, silently, for such an override.

Pass the group's real text instead, " ".join(tok.text for tok in
group), which _list_tokens_for already builds the tuple form of (no
extra parse-path walk: _initials_lists walks tokens directly and
never calls the cheap _list_for string view). A correct override
that forwards tokens is unaffected, since the token path still wins
whenever tokens is passed.

Before: WidensOnly("john e smith").initials() == ""
After:  WidensOnly("john e smith").initials() == "j. s."  (the
        PRE-#528 answer -- the STRING path's vocabulary fallback
        reads "e" as the connective, where the token path's
        parse-backed reading, restored by #528, does not)
Forwarding still receives the fix either way:
        Forwards("john e smith").initials() == "j. e. s."
        HumanName("john e smith").initials() == "j. e. s."

Updates the STATED BREAK comment in _process_initial, the R3 entry
in docs/design/decisions.md (written in this PR, not yet on master,
amended in place), the 2.4.0 Fix bullet in docs/release_log.rst, and
tests/v2/test_facade.py's WidensOnly negative control to assert the
new pre-#528 answer instead of "".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Second review round on #528 found that _initials_lists moved from
reading self.first_list/middle_list/last_list -- public properties a
v1 subclass may override -- to calling self._list_tokens_for(member)
directly. A subclass overriding one of those properties is now
silently ignored by initials(), while last_base/surnames/given_names
(via _split_last, which still reads self.last_list) keep honoring it.

Measured before this fix (three-property Sub, "john Xavier smith"):
  pre-#528 (338daf7):        "J. Z."
  84d9000 (the bug):         "j. X. s."
  this commit:               "J. Z."
And a one-property override (SubLastOnly.last_list -> ["Zorro"], on
"john e smith"), pinning that an un-overridden member keeps the
(post-#528) token path:
  this commit:                "j. e. Z."

Fix: _initials_lists now checks, per member, whether the class
overrides that property (getattr(type(self), f"{member}_list") is not
getattr(HumanName, f"{member}_list") -- three cheap identity checks,
nothing on the parse path). An overridden member takes the pre-#528
STRING path over the override's own strings (reproduced from `git
show 338daf7:nameparser/_facade.py`, all-particle readmission and the
`if n` filter included); an un-overridden member is unchanged. Also:
zip(words, conjunctions, strict=True) in _process_initial, so a future
filter on one branch raises instead of silently truncating.

Verified 0 differences in HumanName(n).initials() across every name in
tools/differential/corpus*.jsonl (527 distinct) between 84d9000 and
this tree -- the plain, un-overridden facade path is unchanged.
tests/v2/test_benchmark.py stays 17 passed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Second review round on #528/the *_list-override fix found several
comments that had drifted from what the code and tests actually do:

- tests/v2/test_facade.py: the "deterministic" claim on
  _list_tokens_for's repeat-call assert was false (the method rebuilds
  fresh lists every call); reworded to what the assert actually pins
  -- an equal grouping, which a one-shot walk behind tokens_for()
  would not give a second time.
- tests/v2/test_facade.py: fixed the __getstate__/__setstate__
  attribution -- HumanName defines its own pair in _facade.py, not
  nameparser/_types.py's guarded pair (a different set of classes).
- tests/v2/test_facade.py: "initial-shaped" misused the repo's term
  (matches _render._INITIAL's shape, which "e" does not); reworded to
  say the parse TAGS "e" an initial via rules.md#P3's one-case fork,
  which is exactly why the bare-word vocabulary fallback disagrees.
- tests/v2/test_facade.py: added the multi-token-group pin the
  reviewer measured on "Ph. D., John" (HumanName/Forwards/WidensOnly
  all "J. P D.", WidensOnly with initials_separator="-" giving
  "J. P-D."), the one shape that exercises the join's actual form.
- tests/v2/test_facade.py + nameparser/_facade.py: added tests and a
  comment pinning that _token_is_conjunction's _resolve() call is
  load-bearing for a keyword-constructed HumanName, not just for
  unpickled/copied instances.
- tests/test_initials.py: corrected which of #528's four movers has
  rows in _CROSS_RULE_WINNERS/_RECORDED_DIFFS ("john e jones", not
  "jones, john e") and why -- the connective-run ledger rule's
  operative half is a `(?=\s)` lookahead that a string-final letter
  can't satisfy -- and named the rule instead of describing it loosely.
- nameparser/_render.py: _reads_as_conjunction's docstring and its
  _INITIAL comment claimed it is asked "only of text the parse never
  saw", which 84d9000 falsified -- a widen-only _process_initial
  override now sends parse-classified text down this path too.
  Reworded around the caller's contract (no token passed, whether or
  not the parse read the text) and added __setstate__ (pickle,
  copy.copy, copy.deepcopy) as a producer alongside replace().
- docs/design/decisions.md, the unlanded 2026-09-13 #528 R3 entry:
  recast the remedy illustration onto "john e smith" (Forwards gives
  "j. e. s.", WidensOnly "j. s.") since every widened override gives
  "John Quincy Smith" its "J. Q. S." back regardless, making that
  example vestigial; added a paragraph recording this PR's F1 fix (an
  overridden public *_list property is honored, taking the pre-#528
  string path for that member -- measured "J. Z." and "j. e. Z." on
  the two Sub subclasses); corrected the copy.copy/copy.deepcopy
  measurement date (2026-09-14, not 09-13 -- the pickle half stays
  09-13); and added the population figure: over the deduped
  tools/differential/corpus*.jsonl glob (1174 distinct names) exactly
  6 give a different initials() after a pickle/copy round trip than
  live, the same six the STATED BREAK paragraph names.
- docs/release_log.rst: the 2.4.0 Fix bullet now also states that a
  subclass overriding one of the public first_list/middle_list/
  last_list properties keeps working, that member taking the pre-2.4
  vocabulary reading.

The P3 bullet near decisions.md line 330 (landed on master) is
untouched; verified byte-identical to the 338daf7 copy.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Widens the unlanded #528 R3 entry's second accepted cost from "the
state-restoration path" to all three no-parse paths: pickle/copy.copy/
copy.deepcopy AND the keyword constructor (HumanName(first=..., ...)
never runs the tokenizer either), with measured keyword-constructor
numbers and a corpus-wide recompute (1174 distinct names via the two
line shapes in tools/differential/corpus*.jsonl, 1 mover before #528,
7 now). Widens the release_log.rst 2.4.0 Fix bullet's parenthetical to
match. Corrects the overridden-*_list paragraph's fixture description
(all three overrides, not just last_list) and adds its "Pinned at"
pointer. Fixes a misattributed parenthetical (only last_base routes
through _split_last; surnames and given_names never do). Fixes "all
four" to "all three" for the pickle/copy.copy/copy.deepcopy count.
Adds a one-sentence reading recipe for "(1174 distinct names)" at its
first occurrence. Renames the wrong method in the _initials_lists
docstring in nameparser/_facade.py (_list_tokens_for, not
first/middle/last, is the private token walk).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The decisions entry's second cost now names three no-parse paths and
cites one test; that test covered two of them. A name rebuilt from
the live parse's own fields now asserts the same fallback values, so
the citation covers what the paragraph claims. The override test's
comment also repeats the corrected attribution: only last_base goes
through _split_last; surnames and given_names read the properties
directly.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@derek73
derek73 merged commit 1f78bef into master Sep 15, 2026
11 checks passed
@derek73
derek73 deleted the claude/528-facade-initials branch September 15, 2026 03:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

1 participant