Skip to content

Render views honor the parser's decisions instead of re-deciding (#458) - #463

Merged
derek73 merged 14 commits into
masterfrom
fix/458-461-render-honors-the-parse
Aug 30, 2026
Merged

Render views honor the parser's decisions instead of re-deciding (#458)#463
derek73 merged 14 commits into
masterfrom
fix/458-461-render-honors-the-parse

Conversation

@derek73

@derek73 derek73 commented Aug 30, 2026

Copy link
Copy Markdown
Owner

A render view stops re-deciding what the parser already settled.

Closes #458

The contract

The parse decides it; the render views honor those decisions and never re-evaluate them. mechanisms.md gains RENDER-HONORS-THE-PARSE for it. Two directions break it, and this PR fixes one:

Re-deriving_cap_word re-ran the conjunction-versus-initial decision from the word's spelling, against a hand-maintained copy of the pipeline's _INITIAL pattern, while classify had already answered it and recorded it on the token. The two copies had stopped asking the same question: _classify.py uses is_initial() — shape ANDed with a script-repertoire test since #320 — where _render.py used the bare pattern. It also asked per WORD of a token's text rather than per token, so juan e-f smith repaired to Juan e-F Smith while JUAN E-F SMITH gave Juan E-F Smith. One name, two answers. (#458)

Overriding — named in the entry, filed as #461, not fixed here. It was implemented on this branch and backed out: enforcing rules.md#R3's "even then" clause made initials() disagree with family_base about the same token. The clause itself is now the open question.

The fallback, and the discriminator that took three tries

#458 alone regressed the canonical Spanish case on the assigned-field path:

h = HumanName('john smith'); h.last = 'velasquez y garcia'; h.capitalize(force=True)
1.4.0 → 'Velasquez y Garcia'    2.1.0 → 'Velasquez y Garcia'    #458 alone → 'Velasquez Y Garcia'

ParsedName.replace() splices raw text into a field, so those tokens carry no decision to honor and case repair falls back to the vocabulary.

The tell is UNCLASSIFIED_TAG, stamped at the two sites that produce unread textreplace(), and the facade's v1 pickle load, which rebuilds a name from *_list strings. It is not untaggedness, since an ordinary parsed name word carries no tags either. And it is not span is None, which was tried and is wrong in the other direction: span-less means synthetic, and Parser.revise() builds span-less tokens from a full sub-parse whose tags it keeps on purpose, so the span reading overrode exactly the tags revise() exists to preserve — revise(middle='e-f') repaired to e-F where the parse gave E-F. With the mark, revise() agrees with the parse on both questions in both views.

Only one view can fall back

capitalized(lexicon=...) is handed a vocabulary. initials() is not — its signature is (spec, delimiter, separator) — and ParsedName holds no lexicon, its fields being original, tokens and ambiguities.

A fallback was written there and dropped before merge. It had to guess Lexicon.default(), and under a caller's own vocabulary the guess erased a whole field:

Parser(lexicon=Lexicon.default().add(particles={"y"}))
  parse("Juan de y").initials()                          'J. d. y.'
  parse("Juan Perez").replace(family="de y").initials()  'J. d. y.' before, 'J.' with the fallback

Under the caller's lexicon de y is an all-particle family whose words are name words; under the default one y is a conjunction and de a particle, so both are skipped. initials()'s body is therefore byte-identical to master's — only its docstring changed.

Accepted, and recorded rather than left to be re-derived as a bug: replace(family='de la vega').initials() is j. d. l. v. where the facade and a parse both give j. v.. That defect is v2-core-only and has shipped since 2.0.0. #464 asks for the missing Parser.initials crossing, which is what would make a fallback there answerable rather than a guess.

Verification

  • 0 of 1094 corpus names move under the shipped vocabulary — 6564 name/variant/lexicon rows, 13128 capitalized calls.
  • Gate green at all three baselines: corpus 1094, intentional diffs 229 / 194 / 102, unexplained: 0.
  • 6116 → 6125 passed; mypy, ruff, sphinx doctests and the README doctest all clean.
  • Every new assertion mutation-verified, each killed by its intended test.

_INITIAL left and came back

#458 removed its last reader and the constant was deleted. The fallback then needed the shape carve-out — a bare in lex.conjunctions breaks middle='e.'John E. Smith — so it returned with one genuine reader. The two-copy sync obligation is narrower than before, not retired: a fallback is right only while it answers as the pipeline would.

Review

Five agents; the round found two regressions this branch had introduced (both above, both fixed) and a set of false claims in prose, since corrected. Findings worth carrying:

  • The initials() fallback's two regressions were invisible to the suite and to the corpus gate, because both need a caller-supplied Lexicon and the shipped vocabulary keeps particles and conjunctions disjoint.
  • docs/usage.rst had a doctest asserting the pre-change behavior. uv run pytest covers --doctest-modules over nameparser/ but not the .rst files; CI runs those through sphinx-build -b doctest.
  • A prose reference of the form decisions.md, under R3 escapes test_doc_internal_anchors_resolve, whose regex requires a #. Two such pointers were dead and are fixed; widening the test is left for its own review.

Recorded, not fixed

RENDER-HONORS-THE-PARSE carries its limits: only a view handed a vocabulary can fall back; family_base and family_particles are properties and cannot; and _cap_word's PARTICLE conjunct still keys on the lexicon handed to the view rather than on the particle tag, so a repair run under a lexicon other than the parse's re-decides a word the parse already read. Making it read the tag would move a boundary rules.md#R4 states in prose, so it is a separate decision.

rules.md#R3's conjunction carve-out is now scoped to the middle and base family words, with the given group declared unsettled: a conjunction there has always initialed (parse("Duke of Edinburgh")D. o. E., 25 of 1094 corpus names, all default-reachable). No deviates: marker, because the intended value is not derivable — R3 and P3 answer differently and no entry has adjudicated it.

#408 is the same shape in a third view and can cite the entry rather than re-argue it.

Found while measuring, filed separately

🤖 Generated with Claude Code

derek73 and others added 4 commits August 29, 2026 18:19
`_cap_word` re-ran the conjunction-versus-initial decision at render
time from the word's spelling, instead of reading the tag `classify`
had already set. Every other view honors that tag; only case repair
asked again. It now reads `"conjunction" in tags`.

The two copies were not asking the same question, which is why this is
more than a tidy-up. `_classify.py` asks `is_initial(token.text)` --
the shape test ANDed with a repertoire test since #320 -- while
`_render.py` asked the bare `_INITIAL` pattern, shape only, and asked
it per WORD of a token's text rather than per token. So `juan e-f
smith` capitalized to `Juan e-F Smith`, the Italian conjunction `e`
lowercased inside a hyphenated middle name that the parse had read as
one ordinary name word, while `JUAN E-F SMITH` gave `Juan E-F Smith`.
Both give `Juan E-F Smith` now.

It also retires the hand-sync obligation the module carried in a
comment -- "keep in sync with `nameparser/_pipeline/_vocab.py` by
hand". A tag read has nothing to keep in sync, and `_render._INITIAL`
had no other reader, so it is deleted rather than kept alive for its
own sync assertion: an unread copy pins nothing about behavior, and
`test_regex_sync` still pins `_vocab._INITIAL` against the public
`REGEXES["initial"]`, which is the relationship worth keeping.

Measured, re-derived on this branch: 0 of the 1094 differential-corpus
names move under `capitalized()` or `capitalized(force=True)`, and 0
move with each name also re-run uppercased and lowercased (6565
name/variant/lexicon rows; the second lexicon puts `y` in `particles`
so a token can carry `conjunction`, `particle` and the unjoined mark at
once). The R5 force-versus-case counts hold at 62/16 through the facade
and 63 through the core. The gate is blind to case repair either way
and its counts are unchanged: 229 / 194 / 102 intentional diffs,
unexplained 0, at 1.4.0 / 2.0.0 / 2.1.0.

What moves outside that population is the hyphenated shape above, and
untagged tokens: a value `replace()` splices in carries no reading for
repair to honor, so a family set to `de y` now repairs to `de Y` --
rules.md#R4's Accepted boundary read in the other direction, since
`revise()` classifies the value and keeps `de y`. The PARTICLE conjunct
still keys on lexicon membership and is untouched; the parenthetical in
decisions.md's replace/revise bullet that expected #458 to change it
too is answered there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…le part (#461)

The unjoined mark readmits the words of an all-particle part to
`initials()`, because none of them is acting as a particle there. It
says nothing about a conjunction, and rules.md#R3 excludes one "even
then" -- "A CONJUNCTION never initials, so a base that is one
contributes nothing even then". The override readmitted both tags, so
the rule and the code disagreed. It is now narrowed to the tag the mark
is about.

The reach is a caller's own vocabulary alone: `particles` and
`conjunctions` are disjoint in the defaults and in every locale pack,
so 0 of the 1094 corpus names move and the gate holds at 229 / 194 /
102 intentional diffs, unexplained 0, at 1.4.0 / 2.0.0 / 2.1.0. Under
`Lexicon.default().add(particles={'y'})` three corpus names move, all
in the fixed direction: `Juan de y` J. d. y. -> J. d., `der, y van`
d. y. v. -> d. v., and `johnny y` j. y. -> j., the last being R3's "a
base that is one contributes nothing" read literally.

What makes it worth fixing over a shape nothing shipped can reach is
that the two views disagreed about ONE token: `Anh y Van` capitalized
as `Anh y Van`, honoring the carve-out, and initialed as `A. y. V.`,
ignoring it. So the test asserts both views on the same parse. That
also closes a hole on the other side: case repair's conjunction
conjunct is deliberately ungated on the mark for exactly this rule, a
decision recorded in decisions.md#R4 and argued at the code -- and
gating it passed the entire suite until this test existed.

R3 gains prose rather than an example line, for the same reason R4's
carve-out has none: the doc runner parses with the default vocabulary,
over which the shape is unreachable, so the pin lives in
tests/v2/test_render.py and the document says where.

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

Reading the tag and nothing else regressed every ASSIGNED field holding
a conjunction, because a value `replace()` splices into a field is
never classified and so carries no tag to read. Measured on the
released 1.4.0 and 2.1.0 wheels, both agreeing and both broken by the
tag-only reading:

    h.last = 'velasquez y garcia'  ->  John Velasquez y Garcia
    h.middle = 'e.'                ->  John E. Smith
    h.last = 'smith-y'             ->  John Smith-y
    h.first = 'y'                  ->  y Smith

That is the canonical Spanish surname and the shape `y` is in the
conjunction vocabulary for, so it is a regression to fix rather than a
boundary to accept.

A synthetic token -- `span is None`, which `ParsedName.replace()`
builds and every parsed token has -- was never read, so there is no
decision to honor and the view asks the vocabulary, which gives the
answer the parser would have given. "Untagged" cannot be the test: an
ordinary parsed name word carries no tags either (`velasquez` parses to
`tags=[]`). The span is the tell.

The same fallback in `initials()` fixes a defect older than #458 and
found only beside it: `replace(family='velasquez y garcia').initials()`
gave `v. y. g.` and `replace(family='de la vega').initials()` gave
`d. l. v.`, where parsing those names gives `v. g.` and `v.`. The v2
core is the whole reach -- `HumanName.initials_list()` computes from
the field strings with its own lexicon and already gave both answers at
1.4.0. `initials()` takes no lexicon, so the fallback reads the cached
`Lexicon.default()`, and only where a spanless token is present.

HOW MUCH each view falls back is not the same, and the difference is
the view rather than the question. Whether a word is the conjunction or
an initial is a property of the word, so both views ask it -- v1's
initial carve-out included, which is why `_render._INITIAL` is back,
now with one reader (`_reads_as_conjunction`) whose input is precisely
the text `_vocab` never saw, and why test_regex_sync's two-copy
assertion matters more rather than less: the fallback is right only
while it answers as the pipeline would. Whether a particle is ACTING as
a particle is a property of the whole PART. Case repair walks one word
at a time and never sees the part, so it does not ask -- widening it
would make `replace(family='de la')` give `De La` and thereby reverse
the replace/revise boundary rules.md#R4 records, with `revise()` as its
documented crossing. `initials()` holds every token of the role at
once, so it does ask, by `_types._remarked`'s own test -- every word of
the part carries `particle` -- with the vocabulary standing in for the
tags a spliced token never got.

Scoped per ROLE, not per name: a role the parse classified whole is
decided by its tags however the other roles were built. On a fully
parsed role the recomputation and the recorded mark agree by
construction, which is what makes per-name scoping pass every other
test in the suite; a hand-built name pins the difference.

Verified, all three agreeing where before the core disagreed on two:

    assigned family        core before    core now   facade   parse
    de la vega             j. d. l. v.    j. v.      j. v.    J. v.
    de la                  j. d. l.       j. d. l.   j. d. l. J. d. l.
    velasquez y garcia     j. v. g.       j. v. g.   j. v. g. J. v. g.
    van der berg           j. v. d. b.    j. b.      j. b.    J. b.
    van der                j. v. d.       j. v. d.   j. v. d. J. v. d.
    smith                  j. s.          j. s.      j. s.    J. s.

`replace(family='velasquez y garcia')` capitalizes to
`Velasquez y Garcia`; `replace(family='de la')` still capitalizes to
`de la` and `revise(family='de la')` still to `De La`; the parsed half
of #458 stands, `juan e-f smith` still capitalizing to `Juan E-F
Smith`. Over the whole branch, 0 of the 1094 corpus names move under
the shipped vocabulary in any of the four views, and the gate is
229 / 194 / 102 with 0 unexplained at all three baselines.

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

The parse decides it; the render views honor those decisions and never
re-evaluate them. #458 and #461 are the two directions that breaks in
-- re-deriving the answer from the text against a view's own copy of a
pipeline predicate, and honoring the record then overriding it -- and
#408, filed and open, is a third instance that should cite the entry
rather than argue it again from scratch.

The Known-limit clause carries the boundary the third commit
established: a token the parse never saw carries no decision to honor,
so a view falls back to the vocabulary, with how far a view may fall
back differing per VIEW rather than per question. Within a view the
line is drawn per question, as rules.md#R4's Accepted clause says; what
differs between views is which questions they can answer at all, since
`initials()` holds every token of the role and `_cap_word` walks one
word of one token.

Two limits are recorded rather than papered over, both measured while
writing this and both falsifying a flatter first draft. The fallback's
agreement with the pipeline is held by HAND: test_regex_sync pins the
two `_INITIAL` copies to each other and to config, while the #320
repertoire half is deliberately not carried across, so a caller-added
conjunction in an initialless script initials from a spliced field
where the same word parsed does not. And `_cap_word`'s PARTICLE
conjunct still keys on the lexicon handed to the view rather than on
the tag -- `parse('juan smith vega')` repairs to `Juan Smith vega`
under `Lexicon.default().add(particles={'vega'})` -- which
decisions.md#R4 records as NOT DONE because reading the tag there moves
a boundary rules.md#R4 states in prose.

`_cap_word`'s comment cites the new entry twice, verbatim, for the two
claims it was making in free prose: the honoring claim (which had been
citing VOCAB-TAGS, whose quoted excerpt is about STAGES, and a view is
not a stage) and the unparsed-token fallback. VOCAB-TAGS keeps its
"a view is" trigger and now points across, so the two entries do not
answer the same question without precedence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
usage.rst's replace() passage documented and doctested the behavior
this branch changes: it said the particles 'start contributing
initials' and asserted 'J. d. l. V. S.'. Since the fallback commit
initials() answers as a parse would, so the doctest asserts 'J. V. S.'
and the prose says which views degrade and which do not.

The distinction is structural, not an oversight, and mechanisms.md's
RENDER-HONORS-THE-PARSE known-limit clause now carries it: a view can
fall back only if it HOLDS a vocabulary. initials() and capitalized()
are handed one; family_base and family_particles are properties on
ParsedName, whose fields are original/tokens/ambiguities and nothing
else. A spliced field still empties the particles view and leaves the
base the whole field, with revise() the crossing there too.

Caught by CI, not locally: uv run pytest covers --doctest-modules over
nameparser/ but not the .rst files, which CI runs through
sphinx-build -b doctest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@derek73 derek73 added this to the v2.2 milestone 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 (82a7bd1) to head (122e75d).

Additional details and impacted files
@@           Coverage Diff           @@
##           master     #463   +/-   ##
=======================================
  Coverage   98.65%   98.65%           
=======================================
  Files          45       45           
  Lines        3186     3193    +7     
=======================================
+ Hits         3143     3150    +7     
  Misses         43       43           

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

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

…part

#461 narrowed the unjoined mark's readmission to the `particle` tag, so
that a conjunction standing inside an all-particle part contributed no
initial, on the authority of rules.md#R3's "A CONJUNCTION never
initials, so a base that is one contributes nothing even then". Backed
out here, before the branch merges.

The mark is a statement about a whole PART -- none of its words is
doing the work its tag names -- and #461 honored it for some of the
part's words while keeping one of them out. Under
`Lexicon.default().add(particles={'y'})`, `parse("Juan de y")` is such
a part: `family_base` is `de y`, both words carry the mark, and #461
initialed `J. d.`, admitting the `de` as the name word the mark makes
it and refusing the `y`. R2's reasoning does not split that way. A
particle with nothing left to join is not acting as a particle, which
is why the mark exists; a conjunction inside the same part has nothing
left to join either, at the same moment and for the same reason, so it
is a name word of that part and initials with the rest. `J. d. y.` is
restored.

That is scoped to a part the mark has ALREADY turned into name words,
and not to any conjunction that joins nothing. Outside such a part the
skip stands: `Juan Velasquez y Garcia` initials `J. V. G.` over base
`Velasquez y Garcia`, `Juan y Garcia` gives `J. G.`, and `Juan de y`
under the default vocabulary still gives `J.` -- none of them touched
here or by #461.

So what is wrong is R3's "even then" clause, which carries the
carve-out into the one part where the joining has stopped, rather than
the code that did not implement it. The clause is left standing and the
question goes back to the issue; the paragraph #461 added under R3 is
removed, and decisions.md records the attempt, what it broke and where
the question went. mechanisms.md#RENDER-HONORS-THE-PARSE keeps the
OVERRIDING direction -- it is a real hazard and the entry needs both
directions -- with its instance now reading as open, the way that entry
already handles #408.

No shipped name is affected in either direction: `particles` and
`conjunctions` are disjoint in the default vocabulary and in every
locale pack, so 0 of the 1094 corpus names move and the gate holds at
229 / 194 / 102 intentional diffs, unexplained 0, at 1.4.0 / 2.0.0 /
2.1.0.

#461's test is split rather than deleted. Case repair's ungated
conjunction conjunct is unaffected by any of this and rests on R4's own
sentence, so it keeps its half as
test_repair_keeps_a_conjunction_lowercase_in_a_particle_part -- gating
that conjunct passed the entire suite until #461's test existed, which
is the part of #461 worth keeping. The initials half is re-asserted at
today's value as test_initials_readmits_a_conjunction_in_a_particle_part,
doing what a `deviates:` marker would do if one could hang here: no
marker can, because markers hang on rules.md example lines and every
line there parses with the default vocabulary, which cannot reach this
shape. So settling #461 will fail the suite until that pin moves with
it, and the values the three documents quote in prose cannot go stale
unnoticed.

Found while writing the reversal, recorded in decisions.md and not
fixed here: R3's "A CONJUNCTION never initials" is unqualified, and a
conjunction in the GIVEN group has always initialed -- `John and Jane
Smith` gives `J. a. J. S.`, `Duke of Edinburgh` gives `D. o. E.` -- 25
of the 1094 corpus names, reachable from the default vocabulary and so
markable, unlike this one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@derek73 derek73 changed the title Render views honor the parser's decisions instead of re-deciding (#458, #461) Render views honor the parser's decisions instead of re-deciding (#458) Aug 30, 2026
derek73 and others added 7 commits August 29, 2026 23:31
… one

The #458-review fallback made `initials()` read a spliced field the way
the parser would have. It had to reach for `Lexicon.default()` to do it,
because `initials()` takes no lexicon -- and guessing the default
vocabulary for a name parsed under a custom one is worse than answering
from tags alone. Measured: under
`Lexicon.default().add(particles={'y'})`, `parse("Juan de y").initials()`
is `J. d. y.` and `parse("Juan Perez").replace(family="de y").initials()`
was `J. d. y.` before the fallback and `J.` after it -- a whole field's
initials gone, because the default vocabulary reads `de y` as
particles-plus-conjunction where the caller's reads it as an
all-particle part.

`_cap_word`'s fallback STAYS: it is handed the caller's lexicon, so it
guesses nothing, and it is what keeps `h.last = "velasquez y garcia"`
repairing as v1 does.

The defect the fallback aimed at is real and goes back to being unfixed:
`replace(family='de la vega').initials()` is `j. d. l. v.` where the
facade and a parse both give `j. v.`. That is a 2.0-core defect and it
wants the `Parser.initials` crossing that does not exist -- rules.md#R3's
Accepted clause and decisions.md now say so, and an issue is drafted for
the crossing.

Removes `_all_particles`, `_is_particle_word`, `_initials_from` and the
`unparsed_lex` scan; `_INITIAL` and `_reads_as_conjunction` stay, case
repair being their reader.

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

`span is None` means SYNTHETIC, which is a wider set than unclassified.
`Parser.revise()` builds span-less tokens too, from a full sub-parse
whose tags it keeps on purpose, and its docstring promises the
tag-driven views "behave as if the text had been parsed" -- so keying
case repair's fallback on the span overrode exactly the tags revise()
exists to preserve. Measured against 82a7bd1:

    revise(name, middle="e-f").capitalized(force=True)
        base 'e-F', parse 'e-F'   -- agreed
        span discriminator: 'e-F' where the parse now gives 'E-F'
    Parser(lexicon=Lexicon()):
        parse("john de la vega").initials()          j. d. l. v.
        revise(family="de la vega").initials()  base j. d. l. v.,
                                                span discriminator j. v.

UNCLASSIFIED_TAG replaces it, single-sourced in _types.py beside
UNJOINED_TAG and FOLDED_TAG, and stamped by the two producers of
genuinely unclassified text: ParsedName.replace(), and the facade's v1
pickle load, which rebuilds a name from the *_list strings and no tags.
Without the second, a restored `juan ortega y gasset` would repair to
`Juan Ortega Y Gasset` -- neither 1.4.0's answer nor the same object's
before pickling. Pinned by a new test.

A HAND-BUILT span-less token is not marked, so it takes the tag path
like every other token in the library. That moves one reading against
2.1: an untagged token whose text is conjunction vocabulary now
capitalizes. The particle conjunct is untouched -- it keys on lexicon
membership, not on the mark -- so a hand-built `de la Vega` is
unchanged.

`_cap_word` reads the mark out of the tags it was already given, so the
`parsed=` keyword goes away rather than changing meaning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each was checked against the released wheels or re-derived on this
branch before being rewritten.

release_log.rst -- "HumanName.initials() and initials_list() already
gave every one of these answers" was false: `h.middle = "E."` then
`h.initials()` gives `j. s.`, not `j. E. s.` (#462). The bullet it
stood in described the initials fallback and went with it in the first
commit of this series; what remains is the #458 bullet, whose 1.4.0
parity claim is now scoped -- v1's predicate over TODAY's vocabulary,
which is narrower than parity, because `h.last = "хосе и мария сантос"`
gives `Хосе И Мария Сантос` on the 1.4.0 wheel and `Хосе и Мария Сантос`
here, the Cyrillic `и` being a 2.x conjunction and not a 1.4.0 one.
_render.py carried the same claim and carries the scoped one now.

decisions.md, three re-derivations:
  - `_render._INITIAL` "is deleted, its last reader gone" -- it was
    deleted and restored in the same PR; case repair's fallback reads
    it, and the hand-sync obligation is narrower rather than retired.
  - "6565 name/variant/lexicon rows" does not derive. It is 6564:
    1094 x 3 spellings x 2 lexicons, 13128 capitalized calls. 0 rows
    move between 82a7bd1 and here.
  - the R5 force-versus-case counts. Both counts move with the
    surface, not just the upper one: core 63/18, facade 62/16. The
    facade drops `Jane van der Berg née y Jones` in the upper
    direction and `John van der J. V` / `abdul V Smith` in the lower.

mechanisms.md -- "the two that can fall back reach for
Lexicon.default(), not the parse's lexicon" is false for
`capitalized()`, which reads the lexicon it is handed; after the first
commit here only one view falls back at all. "_cap_word cannot [answer
the part question], walking one word of one token" is false as stated
-- it is handed the whole token's tags and gates on UNJOINED_TAG; what
it cannot do is RE-DERIVE the answer where no word of the part carries
a tag. Its two `parse(...)` values are true only under
`Lexicon.default().add(particles={'y'})` and are now scoped there: under
the default vocabulary `Anh y Van` initials `A. V.` and `Juan de y`
initials `J.`, the second being a test-pinned rules.md#R3 example line.

Three documents disagreed on whether R4's conjunction carve-out stands
on its own or rests on R3's clause. R4's own text says it rests on R3
("the carve-out R3 states for initials"), so mechanisms.md's "in its
own right" is the one that moves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#458 moved the conjunction-versus-initial decision into the parse, and
the facade's pickle carries the *_list strings and no tags, so a
restored name is repaired the way 1.4.0 repaired everything -- per
word. `juan e-f smith` capitalizes to `Juan E-F Smith` directly and
`Juan e-F Smith` after a round trip. That is the pickle contract
(strings only, never a re-parse) meeting the tag read; pinned so it is
not rediscovered as a defect in either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
'a view that HOLDS a vocabulary' -> 'is HANDED one', and name which
view that is; 'initials all four words' -> 'every word of that field',
the family being three words and the fourth initial the given name's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The clause said a caller-added conjunction in an initialless script
initials from a spliced field where the same word parsed does not.
`initials()` no longer falls back, so case repair is the divergence's
only reader, and there the two paths differ by lower() versus
capitalize() over a caseless script. Measured: _INITIAL matches '씨.',
_vocab.is_initial does not, and '씨.'.lower() == '씨.'.capitalize().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"both views answer it" -- only case repair does now; and "the views
hold different amounts of the name" was never the reason, they hold
different amounts of VOCABULARY. Also names which conjunct \`_cap_word\`
gates on UNJOINED_TAG, "the mark" now being ambiguous between two.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All seven verified against HEAD before editing; all seven still held.

1. decisions.md#R4's dropped-fallback bullet said "An issue is filed
   for that" -- now true, and named: #464. The same claim in
   test_initials_has_no_lexicon_so_a_spliced_field_is_all_name_words
   gets the number too.
2. mechanisms.md and _render.py both pointed at "decisions.md under
   R3" for why the initials() fallback was dropped. decisions.md has
   no ### R3; the bullet lives under ### R4. Both now cite
   decisions.md#R4, which the anchor test can see. (The prose form
   escaped test_doc_internal_anchors_resolve because its regex wants a
   '#'; widening it wants its own review and is not done here.)
3. mechanisms.md#RENDER-HONORS-THE-PARSE's "Lives in." named
   UNCLASSIFIED_TAG's "two producers" and listed only _types.py.
   _facade.py's v1 pickle load is the second, and it is the site
   test_a_restored_pickle_keeps_v1_conjunction_repair protects.
4. decisions.md#R5 explained the whole facade/core count gap by the
   render spec omitting the maiden name. Re-measured: that is the
   UPPER difference only. The two lower-direction names have an empty
   maiden field in both directions -- str() CONCATENATES adjacent
   roles, so their given/middle and family/suffix boundary moves are
   invisible in the joined string. Counts reproduce: core 63/18,
   facade 62/16.
5. "(`_facade._initials_lists`, v1 parity)" is contradicted by open
   #462. Measured with the fields held fixed: HumanName('Scott E.
   Werner') partitions to Scott/E./Werner at 1.4.0, 2.1.0 and here,
   byte-identical, and initials() on those fields is 'S. E. W.' at
   1.4.0 against 'S. W.' since -- a break INSIDE the facade, not
   upstream of it, reproducible by assigning the three fields by
   hand. The parity label is dropped; the bullet's own per-name
   measurements stand unchanged.
6. rules.md#R3's "A CONJUNCTION never initials" was flatly false over
   25 default-reachable corpus names and only decisions.md said so.
   R3 now scopes the carve-out to the middle and base family words
   and declares the given group unsettled, in prose and with NO
   deviates marker: a marker states the INTENDED value and there is
   none to state -- R3 counts name words while P3 makes a connective
   and its neighbours ONE name word, so "John and Jane Smith" has
   four candidate answers (J. a. J. S. today, J. J. S., J. S.,
   J a J. S. at 1.4.0) and no entry picks one. decisions.md#R2
   records the rejection and its reasoning.
7. mechanisms.md said R3 and R4 "stand or fall together". Measured,
   they already come apart on those same 25 names:
   parse("john and jane smith").capitalized() keeps `and` lowercase
   while .initials() gives 'j. a. j. s.'. The TEXTUAL dependency is
   real and is kept; the behavioral claim is gone.

Also, from the review of this commit: the #464 bullet's 1.4.0 values
are ASSIGNED-form values (h = HumanName('juan smith'); h.last = ...),
and a first pass at this commit re-measured them with the PARSED form
and "corrected" two true claims. The constructions agree from 2.0 on
and disagree at 1.4.0, whose parse joins the conjunction run into one
last_list element while assignment re-splits it -- same `last` string,
different initials. Both claims are restored, each value now names the
construction it came from, and the trap is recorded in the bullet.

No example line added, so corpus_rules.jsonl holds at 241 and the
gate holds at 1094 / 229 / 194 / 102 with unexplained: 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@derek73
derek73 merged commit 805bcf8 into master Aug 30, 2026
11 checks passed
注册 for free to join this conversation on GitHub. Already have an account? 登录 to comment

项目

None yet

Development

Successfully merging this pull request may close these issues.

Render views should honor the parser's conjunction/initial tags instead of re-deciding from the word

1 participant