Render views honor the parser's decisions instead of re-deciding (#458) - #463
Merged
Conversation
`_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>
Codecov Report✅ All modified and coverable lines are covered by tests. 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. 🚀 新建 features to boost your workflow:
|
…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>
… 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>
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
注册 for free
to join this conversation on GitHub.
Already have an account?
登录 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.
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.mdgainsRENDER-HONORS-THE-PARSEfor it. Two directions break it, and this PR fixes one:Re-deriving —
_cap_wordre-ran the conjunction-versus-initial decision from the word's spelling, against a hand-maintained copy of the pipeline's_INITIALpattern, whileclassifyhad already answered it and recorded it on the token. The two copies had stopped asking the same question:_classify.pyusesis_initial()— shape ANDed with a script-repertoire test since #320 — where_render.pyused the bare pattern. It also asked per WORD of a token's text rather than per token, sojuan e-f smithrepaired toJuan e-F SmithwhileJUAN E-F SMITHgaveJuan 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 madeinitials()disagree withfamily_baseabout 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:
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 text —replace(), and the facade's v1 pickle load, which rebuilds a name from*_liststrings. It is not untaggedness, since an ordinary parsed name word carries no tags either. And it is notspan is None, which was tried and is wrong in the other direction: span-less means synthetic, andParser.revise()builds span-less tokens from a full sub-parse whose tags it keeps on purpose, so the span reading overrode exactly the tagsrevise()exists to preserve —revise(middle='e-f')repaired toe-Fwhere the parse gaveE-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)— andParsedNameholds no lexicon, its fields beingoriginal,tokensandambiguities.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:Under the caller's lexicon
de yis an all-particle family whose words are name words; under the default oneyis a conjunction anddea 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()isj. d. l. v.where the facade and a parse both givej. v.. That defect is v2-core-only and has shipped since 2.0.0. #464 asks for the missingParser.initialscrossing, which is what would make a fallback there answerable rather than a guess.Verification
capitalizedcalls.unexplained: 0._INITIALleft 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.conjunctionsbreaksmiddle='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:
initials()fallback's two regressions were invisible to the suite and to the corpus gate, because both need a caller-suppliedLexiconand the shipped vocabulary keepsparticlesandconjunctionsdisjoint.docs/usage.rsthad a doctest asserting the pre-change behavior.uv run pytestcovers--doctest-modulesovernameparser/but not the.rstfiles; CI runs those throughsphinx-build -b doctest.decisions.md, under R3escapestest_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-PARSEcarries its limits: only a view handed a vocabulary can fall back;family_baseandfamily_particlesare properties and cannot; and_cap_word's PARTICLE conjunct still keys on the lexicon handed to the view rather than on theparticletag, 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 boundaryrules.md#R4states 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). Nodeviates: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
HumanName('Scott E. Werner').initials()drops the middle initial — the facade readsEas the conjunction #462 —HumanName('Scott E. Werner').initials()drops the middle initial. A v1 parity break shipped in 2.0.0 and 2.1.0, unclassified; the fields are byte-identical across versions, so the break is inside_initials_listsitself.Parserhas noinitials, so a name parsed under a custom lexicon cannot have that view answered under it #464 —Parserhas noinitialscrossing.rules.md#R3's "a conjunction never initials" is unqualified:Juan de yhas baseyand initialsJ.#461 — reframed after the backout;HumanName('JOHN SMITH MBA').capitalize()givesJohn Smith Mba— a credential acronym is title-cased as an ordinary word #459 — credential acronyms title-cased as ordinary words.🤖 Generated with Claude Code